Generics
A generic is a type with a parameter. Instead of committing to string, you accept a placeholder
T and let the caller — or the compiler — fill it in. This is how you write one function that
works with any type without throwing away type information.
The problem generics solve
Section titled “The problem generics solve”Say you want a function that returns the first element of an array.
Attempt 1 — too specific. Works for strings only, and you’d need a copy per element type.
function firstString(items: string[]): string | undefined { return items[0];}Attempt 2 — any. Type-safe in name only.
function firstAny(items: any[]): any { return items[0];}const n = firstAny([1, 2, 3]);n.toUpperCase(); // no error, crashes at runtimeAttempt 3 — unknown. Safe, but useless: every caller has to re-establish the type.
function firstUnknown(items: unknown[]): unknown { return items[0];}const m = firstUnknown([1, 2, 3]);m + 1; // error: 'm' is of type 'unknown'The missing idea is a relationship: the return type is whatever the array’s element type is. That’s what a type parameter expresses.
function first<T>(items: T[]): T | undefined { return items[0];}
const n = first([1, 2, 3]); // number | undefinedconst s = first(['a', 'b']); // string | undefinedconst u = first<User>([]); // User | undefined<T> declares a type parameter. It’s scoped to the function, and TypeScript fills it in per call.
Generic functions
Section titled “Generic functions”function identity<T>(value: T): T { return value;}
const wrap = <T,>(value: T): T[] => [value]; // arrow formMultiple parameters:
function pair<A, B>(a: A, b: B): [A, B] { return [a, b];}
const p = pair('id', 42); // [string, number]A generic function’s type parameters can appear anywhere a type can:
function mapValues<In, Out>(items: In[], fn: (item: In, index: number) => Out): Out[] { return items.map(fn);}
const lengths = mapValues(['aa', 'bbb'], (s) => s.length); // number[]Constraints with extends
Section titled “Constraints with extends”An unconstrained T could be anything, so you can’t do anything with it. extends narrows what
callers may pass, which lets you use the value.
function longest<T extends { length: number }>(a: T, b: T): T { return a.length >= b.length ? a : b;}
longest('abcd', 'ab'); // stringlongest([1, 2, 3], [1]); // number[]longest(10, 20);// error: Argument of type 'number' is not assignable to parameter of type '{ length: number }'Note what constraints do not do: they don’t collapse T to the constraint. longest('a','b')
still returns string, not { length: number }. That preserved specificity is the point.
Common constraints:
<T extends object> // any non-primitive<T extends string> // string or a string literal type<T extends unknown[]> // any array<T extends (...args: any[]) => any> // any function<T extends keyof U> // a key of another type parameterConstraints referencing other parameters
Section titled “Constraints referencing other parameters”This is where generics get genuinely powerful.
function pluck<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key];}
const user = { id: 1, name: 'Ada', active: true };
pluck(user, 'name'); // stringpluck(user, 'id'); // numberpluck(user, 'email');// error: Argument of type '"email"' is not assignable to parameter of type// '"id" | "name" | "active"'keyof T produces the union of T’s keys, K extends keyof T constrains the second argument to
one of them, and T[K] (an indexed access type) is the type of that property. Three features
combining to make property access fully type-safe.
Picking several keys at once:
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> { const result = {} as Pick<T, K>; for (const key of keys) result[key] = obj[key]; return result;}
const subset = pick(user, ['id', 'name']); // { id: number; name: string }Default type parameters
Section titled “Default type parameters”Give a type parameter a default so callers can omit it.
interface ApiResponse<TData = unknown> { status: number; data: TData;}
const a: ApiResponse = { status: 200, data: 'anything' }; // TData = unknownconst b: ApiResponse<User[]> = { status: 200, data: [] }; // TData = User[]Defaults must come after non-defaulted parameters, and may reference earlier ones:
interface Store<TState, TAction = { type: string }> { state: TState; dispatch(action: TAction): void;}
type Dict<V, K extends string = string> = Record<K, V>;Generic interfaces and type aliases
Section titled “Generic interfaces and type aliases”interface Box<T> { value: T; map<U>(fn: (value: T) => U): Box<U>;}
type Result<T, E = Error> = | { ok: true; value: T } | { ok: false; error: E };
function tryParse(json: string): Result<unknown> { try { return { ok: true, value: JSON.parse(json) }; } catch (e) { return { ok: false, error: e instanceof Error ? e : new Error(String(e)) }; }}
const r = tryParse('{"a":1}');if (r.ok) { r.value; // unknown} else { r.error; // Error}That Result type is a discriminated union with type parameters — the two ideas compose directly.
Generic type aliases can also be recursive, which is how you type JSON:
type Json = | string | number | boolean | null | Json[] | { [key: string]: Json };Generic classes
Section titled “Generic classes”class Queue<T> { #items: T[] = [];
enqueue(item: T): void { this.#items.push(item); }
dequeue(): T | undefined { return this.#items.shift(); }
get size(): number { return this.#items.length; }}
const q = new Queue<string>();q.enqueue('a');const next = q.dequeue(); // string | undefinedThe type argument is fixed per instance. If you construct with an argument, it can be inferred:
class Cell<T> { constructor(public value: T) {}}
const c = new Cell(42); // Cell<number>How inference works
Section titled “How inference works”TypeScript infers type arguments by matching the parameter types against the argument types.
declare function map<In, Out>(items: In[], fn: (x: In) => Out): Out[];
map([1, 2, 3], (n) => String(n));// In inferred from In[] vs number[] => number// Out inferred from the arrow's return => string// Result: string[]Things worth knowing:
Inference goes left to right, and context flows forward. In the call above, In is fixed by
the first argument, which is why n is contextually typed as number inside the callback without
an annotation.
Inference widens literals unless the target is literal-like.
declare function wrap<T>(value: T): T[];const a = wrap('hello'); // string[] — literal widened
declare function wrapConst<const T>(value: T): T[]; // TS 5.0+const b = wrapConst('hello'); // "hello"[]The const type parameter modifier (TypeScript 5.0) tells inference to keep literals narrow —
the equivalent of the caller writing as const.
Multiple inference sites produce a union or an error.
declare function combine<T>(a: T, b: T): T[];combine(1, 'a'); // T inferred as string | number => (string | number)[]If you’d rather forbid that, add an explicit type argument: combine<number>(1, 'a') errors.
NoInfer<T> (TypeScript 5.4+) blocks a position from contributing to inference.
function createStore<T>(initial: T, reset: NoInfer<T>): void {}
createStore({ count: 0 }, { count: 5 }); // T from the first argument onlyWithout NoInfer, an incompatible second argument would silently widen T instead of erroring.
Explicit type arguments switch inference off entirely.
first<string>([1, 2, 3]); // error — no inference to save youSpecify them only when inference gets it wrong or when there’s nothing to infer from
(new Queue<string>()).
Practical generic utilities
Section titled “Practical generic utilities”A handful of small generics that pay for themselves in most codebases.
Type-safe object helpers
Section titled “Type-safe object helpers”Object.keys returns string[], losing key information. A wrapper fixes that — with a caveat.
function keysOf<T extends object>(obj: T): (keyof T)[] { return Object.keys(obj) as (keyof T)[];}
const user = { id: 1, name: 'Ada' };for (const key of keysOf(user)) { user[key]; // number | string, and `key` autocompletes}groupBy
Section titled “groupBy”function groupBy<T, K extends PropertyKey>( items: readonly T[], keyFn: (item: T) => K,): Record<K, T[]> { const out = {} as Record<K, T[]>; for (const item of items) { const key = keyFn(item); (out[key] ??= []).push(item); } return out;}
const people = [ { name: 'Ada', dept: 'eng' as const }, { name: 'Grace', dept: 'eng' as const }, { name: 'Alan', dept: 'ops' as const },];
const byDept = groupBy(people, (p) => p.dept); // Record<'eng' | 'ops', {...}[]>PropertyKey is a built-in alias for string | number | symbol.
A typed event emitter
Section titled “A typed event emitter”type EventMap = Record<string, unknown[]>;
class Emitter<T extends EventMap> { #handlers: { [K in keyof T]?: Array<(...args: T[K]) => void> } = {};
on<K extends keyof T>(event: K, handler: (...args: T[K]) => void): void { (this.#handlers[event] ??= []).push(handler); }
emit<K extends keyof T>(event: K, ...args: T[K]): void { this.#handlers[event]?.forEach((h) => h(...args)); }}
const emitter = new Emitter<{ connect: [url: string]; message: [data: string, id: number]; close: [];}>();
emitter.on('message', (data, id) => { /* data: string, id: number */ });emitter.emit('connect', 'wss://example.com');emitter.emit('connect', 42); // error: Argument of type 'number' is not assignable to 'string'emitter.emit('unknown'); // error: Argument of type '"unknown"' is not assignableTuple types as the argument list is the trick that makes ...args fully typed per event.
Deep readonly
Section titled “Deep readonly”type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];};
type Config = DeepReadonly<{ server: { host: string; ports: number[] };}>;// every level is readonlyThat combines a mapped type, a conditional type, and recursion — all covered in advanced types.
A generic fetch wrapper
Section titled “A generic fetch wrapper”async function getJson<T>(url: string, guard: (value: unknown) => value is T): Promise<T> { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const body: unknown = await res.json(); if (!guard(body)) throw new TypeError(`Unexpected response shape from ${url}`); return body;}Requiring a guard argument prevents the usual getJson<User>(url) mistake, where T is a pure
assumption with nothing checking it. See
patterns and gotchas.
When not to use a generic
Section titled “When not to use a generic”A type parameter that appears only once in a signature is doing nothing:
// Pointless: T is used once, so it's just `unknown` with extra steps.function log<T>(value: T): void { console.log(value);}// Same thing, simpler:function log(value: unknown): void {}The rule: a type parameter earns its place when it relates two or more positions — parameter to
return type, or one parameter to another. Otherwise use a concrete type, unknown, or a union.
Key points
Section titled “Key points”- A type parameter expresses a relationship between types that
anyandunknownthrow away. extendsconstrains what may be passed without collapsing the inferred type.<T, K extends keyof T>plusT[K]is the core idiom for type-safe property access.- Default type parameters (
<T = unknown>) make generics optional for callers. - Inference reads argument types left to right;
consttype parameters keep literals narrow, andNoInfer<T>excludes a position from inference. - Static class members can’t use the class’s type parameters.
- If a type parameter appears once, delete it.