Back to Blog
TypeScript2026-04-25

TypeScript Generics Mastery

Move beyond Array<T> and learn the patterns that make TypeScript libraries elegant.

Generics are the lever that lets one function or type work across many shapes without losing safety. The basics are easy. The leverage — and the incomprehensible error messages — live in constraints, conditional types, and inference. This is a tour of the parts that matter in real libraries.

Constrained Generics

- function pluck<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> { - return keys.reduce((acc, key) => { acc[key] = obj[key]; return acc; }, {} as Pick<T, K>); - }

The K extends keyof T constraint does two jobs: it rejects invalid keys at the call site, and it lets the compiler *narrow* the return type — pluck(user, ['id']) returns a type with only id, not a vague partial. Constraints are how you tell the compiler "I will use this capability", and they should be as loose as possible while still permitting the function body: constraining to { length: number } accepts strings, arrays, and typed arrays alike.

Inference: The Feature Users Never See

- function pipe<A, B, C>(a: A, ab: (a: A) => B, bc: (b: B) => C): C { return bc(ab(a)); }

TypeScript infers all three parameters from the call site; users never write angle brackets. Good generic design is mostly *inference design*. Rules of thumb that follow from how inference works:

- Inference flows from arguments to type parameters, left to right; put the value that determines T before the callbacks that consume it, or callbacks get unknown parameters - A type parameter that appears only once in a signature does nothing — function log<T>(x: T): void is just (x: unknown) => void with extra ceremony - A parameter used only in the return type is a lie to the compiler: fetchJson<T>(url): Promise<T> performs no validation; it is a cast wearing a nicer syntax. Pair it with a runtime validator or accept that you wrote as T

When you need a literal type preserved, const type parameters (TS 5.0) fix the classic widening problem: function tuple<const T extends readonly unknown[]>(...args: T): T infers ['a', 'b'] as a tuple of literals rather than string[].

Conditional Types and infer

- type Awaited2<T> = T extends Promise<infer U> ? U : T;

infer pattern-matches a type and extracts a position. This is the mechanism behind built-ins like ReturnType, Parameters, and Awaited. The sharp edge is distribution: a conditional type applied to a *naked* type parameter distributes over unions. Exclude<'a' | 'b', 'a'> works because of this — the check runs per union member. When distribution is wrong for your case, wrap both sides in tuples: [T] extends [string] treats the union as one unit. Forgetting this is the number one source of "why did my conditional type return a union of both branches".

Also remember never is the empty union: a distributive conditional over never evaluates to never without ever running your branches — useful for filtering, baffling when unexpected.

Variadic Tuples

- type Last<T extends unknown[]> = T extends [...unknown[], infer L] ? L : never;

Variadic tuple types describe head/tail structure, which is what typed pipe, curry, and event-emitter signatures need. Combined with labeled tuple elements, they let a wrapper preserve the exact parameter list of the wrapped function: (...args: Parameters<F>) => ReturnType<F>.

Mapped Types and Key Remapping

Generics compose with mapped types to transform whole shapes:

- type Methods<T> = { [K in keyof T as T[K] extends (...a: never) => unknown ? K : never]: T[K] };

Key remapping with as — optionally combined with template literal types such as Capitalize to build names like getName from name — generates derived APIs — getters, event maps, form state — from one source of truth, so adding a field updates every derived type automatically.

Branded Types

- type UserId = string & { readonly __brand: 'UserId' }; - function asUserId(s: string): UserId { return s as UserId; }

Structural typing means any string fits any string parameter — until you brand it. The intersection with a phantom __brand property exists only at compile time (nothing is added at runtime) and makes UserId and OrderId mutually incompatible, catching swapped-argument bugs. Centralize the as casts in a few constructor functions that validate.

Common Mistakes

- Reaching for any inside generic function bodies to silence errors — use constraints or well-placed as on one line, not a type system opt-out - Over-constraining (T extends object when T would do), which rejects valid callers for no body-level benefit - Deeply recursive conditional types for problems a union and two overloads solve — compile times and error messages both suffer - Trusting return-only type parameters as if they validated data

When to Stop

A generic signature that needs three lines of conditional types to read is usually a sign to split the type or accept a small amount of duplication. Optimize for the call site: the best generic code is the kind whose users never notice the angle brackets at all.