typescript · level 3

Generics

Type parameters, constraints, defaults, inference at the call site.

175 XP

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:

  1. The constraint filters arguments. A call type-checks only if the argument satisfies the constraint.
  2. T is still the actual passed type. Inside the function body, T is "abc" (or string, 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
  • Hover the call site to see exactly what each type parameter resolved to.

    service
  • type-festfree tier

    Battle-tested generic helper types — Promisable, RequireAtLeastOne, Merge, etc.

    library
  • expect-typefree tier

    Assert type-level invariants in your tests so generic regressions fail CI.

    library