Generics
Type parameters, constraints, defaults, inference at the call site.
Generics
Generics let one piece of code work over many types without losing the type information. Array<T> is generic. Promise<T> is generic. Every utility type you've used (Pick, Partial, Awaited) is generic. Once you can read and write generics fluently, the language opens up.
The mental model: type parameters are arguments
A regular function takes value arguments:
function box(x: number) { return { value: x }; }
box(42);
A generic function takes value arguments AND type arguments:
function box<T>(x: T) { return { value: x }; }
box<number>(42); // explicit
box(42); // inferred — T = number
T is a placeholder — for ONE call. Each call to box resolves T independently:
box(42); // T = number
box("hi"); // T = string
box(true); // T = boolean
This is the most important fact about generics. They are not "any" — they are "the specific type the caller used", remembered through the function and back out.
Inference at the call site
You almost never write the type parameter explicitly. TypeScript infers it from arguments:
function first<T>(xs: T[]): T | undefined {
return xs[0];
}
first([1, 2, 3]); // T inferred as number → return type number | undefined
first(["a", "b"]); // T inferred as string → return type string | undefined
When inference can't pin T down (no arguments, ambiguous), you provide it:
function empty<T>(): T[] { return []; }
empty<string>(); // T = string explicitly
empty(); // T = unknown (best TS can do)
Constraints (T extends ...)
Sometimes you want to say "T can be anything as long as it has property X":
function len<T extends { length: number }>(x: T): number {
return x.length;
}
len("abc"); // ✓ string has .length
len([1, 2, 3]); // ✓ array has .length
len({ length: 5 }); // ✓ object literal has .length
len(42); // ❌ number has no .length
Two things to internalise:
- The constraint filters arguments. A call type-checks only if the argument satisfies the constraint.
Tis still the actual passed type. Inside the function body,Tis"abc"(orstring, depending on widening), not just{length: number}. Constraints are lower bounds, not type identities.
This matters when you return a value of type T:
function tap<T>(x: T): T {
console.log(x);
return x; // returns the SAME type that came in, not a widening
}
const s = tap("hello"); // s: "hello" (with const), or string (with let)
If tap widened T to its constraint, the caller would lose information. Generics preserve.
Defaults (T = X)
Generics can have default type arguments — used only when nothing else binds them:
function makeMap<K = string, V = unknown>(): Map<K, V> {
return new Map();
}
makeMap(); // Map<string, unknown>
makeMap<number>(); // Map<number, unknown>
makeMap<number, boolean>(); // Map<number, boolean>
If you provide a value argument that infers K, the default is ignored. Defaults fill in missing type information; they don't override successful inference.
Multiple type parameters
Each parameter binds independently:
function pair<A, B>(a: A, b: B): [A, B] { return [a, b]; }
pair("x", 42); // [string, number]
pair(true, [1, 2]); // [boolean, number[]]
Use one parameter per "axis of variation". Don't reuse the same parameter for two unrelated arguments — it forces them into a single common type:
function bad<T>(a: T, b: T) { /* a and b must agree */ }
bad("x", 42); // ❌ Type 'number' is not assignable to type 'string'
Generic types and interfaces
Same idea, applied to types:
interface Box<T> {
value: T;
put(v: T): void;
}
type Pair<A, B> = [A, B];
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
Pass them like arguments:
const b: Box<number> = { value: 0, put(v) { /* v is number */ } };
const r: Result<string> = { ok: true, value: "hi" };
When to reach for generics
Use them when you'd otherwise write any to escape the type system:
// Bad: loses type information
function clone(x: any): any { return JSON.parse(JSON.stringify(x)); }
// Good: preserves T from caller to caller
function clone<T>(x: T): T { return JSON.parse(JSON.stringify(x)); }
Don't reach for them when one concrete type is fine. Premature generics turn every function into a puzzle:
// Bad — over-engineered
function add<T extends number>(a: T, b: T): T { return (a + b) as T; }
// Good — concrete is fine here
function add(a: number, b: number): number { return a + b; }
Distributive vs non-distributive context
This bites people in mapped/conditional types. When a naked type parameter appears in a conditional:
type ToArray<T> = T extends unknown ? T[] : never;
type X = ToArray<string | number>; // string[] | number[] ← distributed
The conditional distributes over the union — TS evaluates string extends unknown ? string[] : never and number extends unknown ? number[] : never separately and unions the results.
To opt out, wrap the parameter in a tuple to make it non-naked:
type ToArrayN<T> = [T] extends [unknown] ? T[] : never;
type Y = ToArrayN<string | number>; // (string | number)[] ← single
This trick comes up constantly in utility-type design. Memorise the wrapping.
Reading generic signatures
Practice this until it's automatic:
function fetchAll<T>(urls: string[], parse: (raw: string) => T): Promise<T[]>
Read it as: "given a list of URLs and a parser that produces some T, returns a promise of an array of T". Hover any call site and TypeScript will tell you what T became.
That's the entire game. Read the signature, predict the inferred T, write the call.
Tools in the wild
3 tools- serviceTypeScript Playgroundfree tier
Hover the call site to see exactly what each type parameter resolved to.
- librarytype-festfree tier
Battle-tested generic helper types — Promisable, RequireAtLeastOne, Merge, etc.
- libraryexpect-typefree tier
Assert type-level invariants in your tests so generic regressions fail CI.