Skip to content

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.

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 runtime

Attempt 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 | undefined
const s = first(['a', 'b']); // string | undefined
const u = first<User>([]); // User | undefined

<T> declares a type parameter. It’s scoped to the function, and TypeScript fills it in per call.

function identity<T>(value: T): T {
return value;
}
const wrap = <T,>(value: T): T[] => [value]; // arrow form

Multiple 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[]

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'); // string
longest([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 parameter

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'); // string
pluck(user, 'id'); // number
pluck(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 }

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 = unknown
const 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>;
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 };
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 | undefined

The 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>

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 only

Without 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 you

Specify them only when inference gets it wrong or when there’s nothing to infer from (new Queue<string>()).

A handful of small generics that pay for themselves in most codebases.

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
}
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.

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 assignable

Tuple types as the argument list is the trick that makes ...args fully typed per event.

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 readonly

That combines a mapped type, a conditional type, and recursion — all covered in advanced types.

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.

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.

  • A type parameter expresses a relationship between types that any and unknown throw away.
  • extends constrains what may be passed without collapsing the inferred type.
  • <T, K extends keyof T> plus T[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; const type parameters keep literals narrow, and NoInfer<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.