Going DeeperPro· 45 min read

Deeper Generics: Constraints, Defaults & Generic Classes

Make generics genuinely useful: limit them with constraints, give them default types, take several type parameters, and put them in classes and interfaces.

What you will learn

  • Constrain a type parameter with extends
  • Give a type parameter a default and use multiple parameters
  • Write a generic class and a generic interface

From "any type" to "any *suitable* type"

A plain generic <T> accepts literally any type. That is sometimes too loose — what if your function needs the value to at least have a .length, or to be an object you can read a key from? Constraints let you say "T can be any type *as long as* it has these features". This is what turns generics from a toy into a real reusable tool.

Constraints with extends

Add extends SomeShape after the type parameter to require that T matches at least that shape. Here we only want types that have a length property:

extends constrains T to types that have a length
// T can be anything, as long as it has a number length
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

console.log(longest('hello', 'hi'));        // strings have length
console.log(longest([1, 2, 3], [9]));       // arrays have length

Note: Output: hello [ 1, 2, 3 ] Because of T extends { length: number }, you may call .length inside the function safely. Strings and arrays both qualify, so both calls work — but longest(5, 9) (numbers have no length) would be rejected at compile time.

A constraint that links two parameters: keyof

A powerful constraint pattern reads a property from an object safely. K extends keyof T means "K must be one of T’s actual key names". This makes a get-property helper fully type-safe:

K extends keyof T allows only real keys, with the exact value type
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: 'Asha', age: 28 };

const n = getProp(user, 'name');   // typed as string
const a = getProp(user, 'age');    // typed as number
console.log(n.toUpperCase(), a + 1);

getProp(user, 'email');            // error — not a key of user

Note: Output: ASHA 29 keyof T is the union of T’s key names ('name' | 'age'). The return type T[K] is the type of that exact property, so n is a string and a is a number. Asking for 'email' — not a real key — is caught immediately.

Default type parameters

Just like a function parameter can have a default value, a type parameter can have a default type with = .... If the caller does not specify one, the default is used.

T = string supplies a default when none is given
// If no type is given, T defaults to string
class Box<T = string> {
  constructor(public value: T) {}
}

const a = new Box('hello');   // T inferred as string
const b = new Box<number>(42); // T explicitly number

console.log(a.value.toUpperCase());
console.log(b.value + 1);

Note: Output: HELLO 43 Box<T = string> means T is string unless you say otherwise. new Box('hello') infers string; new Box<number>(42) overrides it to number. Defaults keep simple cases tidy while allowing flexibility.

Multiple type parameters

A generic can take several type parameters — name them T, U, K, V, and so on. A pair helper needs two:

Two independent type parameters A and B
function pair<A, B>(first: A, second: B): [A, B] {
  return [first, second];
}

const p = pair('Asha', 28);   // [string, number]
console.log(p[0].toUpperCase(), p[1] + 1);

Note: Output: ASHA 29 Each parameter keeps its own type: A is string, B is number, and the return tuple is [string, number]. TypeScript tracks both independently.

Generic classes and interfaces

Generics live in classes and interfaces too — this is how reusable data structures like a typed stack or a typed API response are built. The type parameter goes right after the name and is usable throughout:

A generic Stack<T> reusable for any element type
// A reusable, fully typed stack of any element type
class Stack<T> {
  private items: T[] = [];

  push(item: T): void { this.items.push(item); }
  pop(): T | undefined { return this.items.pop(); }
  get size(): number { return this.items.length; }
}

const numbers = new Stack<number>();
numbers.push(10);
numbers.push(20);
console.log(numbers.pop());   // a number
console.log(numbers.size);

Note: Output: 20 1 Stack<number> fixes T to number, so push only accepts numbers and pop returns number | undefined. Make a Stack<string> instead and the same class works for strings — one class, every type, fully checked.

A generic interface describes a shape that is parameterised by a type. A typed API envelope is the classic example:

A generic interface ApiResponse<T> carrying any payload
interface ApiResponse<T> {
  ok: boolean;
  data: T;
}

const userResp: ApiResponse<{ name: string }> = {
  ok: true,
  data: { name: 'Asha' }
};

console.log(userResp.data.name);

Note: Output: Asha ApiResponse<T> wraps any payload type in a consistent envelope. Here T is { name: string }, so userResp.data.name is fully typed. The same interface describes every endpoint’s response.

The deeper-generics toolkit

FeatureSyntaxWhy it helps
Constraint<T extends Shape>Require T to have certain features
Key constraint<K extends keyof T>Allow only real key names of T
Default<T = string>A fallback type when none is given
Multiple params<A, B>Track several types independently
Generic classclass Box<T>Reusable typed data structures
Generic interfaceinterface Wrap<T>Reusable typed shapes

Tip: A rule of thumb: start with a plain <T>, and add extends the moment you need to *use* a feature of the value inside the function (like .length or a key). The constraint is what makes that use safe.

Watch out: A constraint narrows what callers may pass — it does not widen it. <T extends { length: number }> rejects a number even though you only read .length. Constrain to the *minimum* shape you actually need, so the generic stays as reusable as possible.

Q. What does the constraint in function longest<T extends { length: number }>(a: T, b: T) guarantee?

Answer: T extends { length: number } lets T be any type with a length property (strings, arrays, ...), and lets you safely read .length inside the function.

✍️ Practice

  1. Write function firstKey<T, K extends keyof T>(obj: T, key: K): T[K] and read two different keys off an object.
  2. Build a generic Queue<T> class with enqueue, dequeue and size, and use it with strings.

🏠 Homework

  1. Create a generic Result<T = string> interface with ok: boolean and value: T, plus a function wrap<T>(value: T): Result<T>. Show it used with a number and with a default string, and note where the default applied.
Want to learn this with a mentor?

CodingClave runs guided, project-based training (28-day, 45-day & 6-month batches).

Explore Training →