Generics
Generics admit writing functions, classes, and types parameterised by other types. TypeScript’s generics are substantial: type parameters with constraints, default type arguments, generic functions and classes, generic interfaces and type aliases, conditional types, and the infer keyword. The combination — type parameters at almost every position, structural compatibility, conditional inference — admits substantial type-level computation. The conventional discipline is to use generics for genuinely generic code and to allow inference where possible (the compiler is substantial at inferring type arguments).
Generic functions
Type parameters appear in angle brackets after the function name:
function identity<T>(x: T): T {
return x;
}
const a = identity<number>(42); // explicit
const b = identity(42); // T inferred as number
const c = identity("hello"); // T inferred as string
The form: function name<T>(params): returnType { body }. Each type parameter has a name (conventionally T, U, V, or descriptive names like Item, Key).
For arrow functions, the form is the same:
const identity = <T>(x: T): T => x;
In .tsx files, the trailing comma is needed to disambiguate from JSX:
const identity = <T,>(x: T): T => x;
Multiple type parameters
function pair<A, B>(a: A, b: B): [A, B] {
return [a, b];
}
const p = pair("hello", 42); // [string, number]
function map<T, U>(arr: T[], f: (x: T) => U): U[] {
return arr.map(f);
}
const lengths = map(["a", "bb", "ccc"], s => s.length);
// lengths: number[]
Generic constraints
The extends keyword admits constraining a type parameter:
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longest("hello", "hi"); // OK (string has length)
longest([1, 2, 3], [4, 5]); // OK (array has length)
// longest(1, 2); // ERROR: number lacks length
The constraint admits using the constrained operations on the parameter:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Alice", age: 30 };
const name = getProperty(user, "name"); // type: string
const age = getProperty(user, "age"); // type: number
// const x = getProperty(user, "email"); // ERROR: "email" not a key
The pattern is one of TypeScript’s most distinctive idioms; treated in Advanced types.
Default type arguments
Type parameters may have defaults:
interface Container<T = string> {
value: T;
serialize(): string;
}
const a: Container = { value: "hello", serialize: () => "..." }; // T = string
const b: Container<number> = { value: 42, serialize: () => "..." };
function fetch<T = unknown>(url: string): Promise<T> {
return /* ... */;
}
const data1 = await fetch("/api"); // Promise<unknown>
const data2 = await fetch<User>("/api/user"); // Promise<User>
The mechanism admits backward-compatible additions and conventional sensible defaults.
Generic types
Type aliases and interfaces admit generics:
type Pair<A, B> = [A, B];
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
interface List<T> {
head: T;
tail: List<T> | null;
}
interface Map<K, V> {
get(key: K): V | undefined;
set(key: K, value: V): void;
}
Instantiation requires the type arguments:
const p: Pair<string, number> = ["alice", 30];
const r: Result<number> = { ok: true, value: 42 };
Generic classes
class Stack<T> {
private items: T[] = [];
push(x: T): void {
this.items.push(x);
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
}
const s = new Stack<number>();
s.push(1);
s.push(2);
const top = s.pop(); // type: number | undefined
const t = new Stack<string>();
t.push("hello");
Methods may have additional type parameters:
class Container<T> {
private value: T;
constructor(value: T) { this.value = value; }
map<U>(fn: (x: T) => U): Container<U> {
return new Container(fn(this.value));
}
get(): T { return this.value; }
}
const c = new Container(42);
const c2 = c.map(n => n.toString()); // Container<string>
Generic interfaces
interface Pair<A, B> {
first: A;
second: B;
}
interface Iterator<T> {
next(): { value: T; done: boolean };
}
interface Comparator<T> {
(a: T, b: T): number;
}
The conventional standard interfaces (Array<T>, Promise<T>, Map<K, V>, etc.) are generic.
Type inference
The compiler is substantial at inferring type arguments:
function map<T, U>(arr: T[], f: (x: T) => U): U[] { /* ... */ }
const result = map([1, 2, 3], n => n.toString());
// ^^ type: string[]
// ^^ T inferred as number
// ^^ U inferred as string
When inference fails or is incorrect, explicit arguments are admitted:
const explicit = map<number, string>([1, 2, 3], n => n.toString());
The conventional discipline lets the compiler infer when possible; explicit arguments are reserved for cases where inference is wrong or missing.
Conditional types
Since TS 2.8, conditional types admit type-level branching:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
The form T extends X ? Y : Z produces Y if T is assignable to X, otherwise Z. The mechanism admits substantial type-level logic.
A common pattern: extracting a type from a union:
type ExtractString<T> = T extends string ? T : never;
type A = ExtractString<string | number | boolean>; // string
// Built-in:
type B = Extract<string | number | boolean, string>; // string
type C = Exclude<string | number | boolean, string>; // number | boolean
The infer keyword
The infer keyword (in conditional types) extracts a type from a generic position:
type ReturnType<F> = F extends (...args: any[]) => infer R ? R : never;
type A = ReturnType<() => string>; // string
type B = ReturnType<(x: number) => boolean>; // boolean
type C = ReturnType<number>; // never (not a function)
type ArrayElement<A> = A extends (infer T)[] ? T : never;
type D = ArrayElement<string[]>; // string
type E = ArrayElement<number[]>; // number
The mechanism is treated in Advanced types.
Variance
TypeScript’s generic types are conventionally bivariant (admit both covariant and contravariant assignments) by default, with several exceptions for type-system soundness. Since TS 4.7, explicit variance annotations are admitted:
interface ProducerOf<out T> {
produce(): T;
}
interface ConsumerOf<in T> {
consume(value: T): void;
}
interface BothWays<in out T> {
transform(input: T): T;
}
The annotations document and enforce variance. The conventional discipline is to omit them unless variance correctness matters; the compiler infers reasonable variance for most cases.
Generic constraints with keyof
function pluck<T, K extends keyof T>(arr: T[], key: K): T[K][] {
return arr.map(x => x[key]);
}
interface User {
name: string;
age: number;
}
const users: User[] = [{ name: "Alice", age: 30 }];
const names = pluck(users, "name"); // string[]
const ages = pluck(users, "age"); // number[]
// const errors = pluck(users, "email"); // ERROR: "email" not a key
The pattern admits substantial type-safe property access.
Generic constraints with type unions
function findById<T extends { id: number | string }>(items: T[], id: T["id"]): T | undefined {
return items.find(item => item.id === id);
}
Generic recursion
Type aliases admit recursive references (within limits):
type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
const data: JsonValue = {
name: "Alice",
nested: [1, 2, { deep: true }],
};
Recursive types are conventional for tree structures, JSON, AST nodes, and similar.
Higher-order types
Generics admit functions that operate on other generic types:
type MapValues<T, U> = {
[K in keyof T]: U;
};
type Lengths = MapValues<{ a: string; b: number }, number>;
// { a: number; b: number }
Treated in Advanced types.
Common patterns
Generic identity-like
function tap<T>(value: T, fn: (x: T) => void): T {
fn(value);
return value;
}
const x = tap(getValue(), v => console.log("got:", v));
Generic factory
function create<T>(Constructor: new () => T): T {
return new Constructor();
}
class MyClass {}
const m = create(MyClass); // type: MyClass
Generic container
interface Box<T> {
value: T;
}
function box<T>(value: T): Box<T> {
return { value };
}
const b = box(42); // Box<number>
const c = box("hello"); // Box<string>
Generic transformation
function map<T, U>(arr: readonly T[], fn: (x: T, i: number) => U): U[] {
const result: U[] = [];
for (let i = 0; i < arr.length; i++) {
result.push(fn(arr[i], i));
}
return result;
}
const lengths = map(["a", "bb", "ccc"], s => s.length);
Generic filter
function filter<T>(arr: readonly T[], pred: (x: T) => boolean): T[] {
return arr.filter(pred);
}
// With type predicate:
function filter<T, S extends T>(arr: readonly T[], pred: (x: T) => x is S): S[] {
return arr.filter(pred) as S[];
}
Generic constraint with default
function fetch<T = unknown>(url: string): Promise<T> {
return /* ... */;
}
const data: User = await fetch<User>("/api/user");
Generic union builder
type Action<T extends string, P = void> =
P extends void ? { type: T } : { type: T; payload: P };
type IncrementAction = Action<"INCREMENT">; // { type: "INCREMENT" }
type SetAction = Action<"SET", number>; // { type: "SET"; payload: number }
Generic reduce
function reduce<T, U>(
arr: readonly T[],
initial: U,
fn: (acc: U, x: T) => U,
): U {
let acc = initial;
for (const x of arr) {
acc = fn(acc, x);
}
return acc;
}
const sum = reduce([1, 2, 3], 0, (acc, n) => acc + n);
Generic class with default
class EventEmitter<T = any> {
private listeners: Array<(value: T) => void> = [];
on(listener: (value: T) => void): void {
this.listeners.push(listener);
}
emit(value: T): void {
this.listeners.forEach(l => l(value));
}
}
const emitter = new EventEmitter<{ name: string }>();
emitter.on(e => console.log(e.name));
emitter.emit({ name: "alice" });
Generic API client
interface ApiClient {
get<T>(url: string): Promise<T>;
post<T, B>(url: string, body: B): Promise<T>;
put<T, B>(url: string, body: B): Promise<T>;
delete(url: string): Promise<void>;
}
const user = await client.get<User>("/api/user/123");
const created = await client.post<User, { name: string }>("/api/users", { name: "alice" });
Generic Result type
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
function divide(a: number, b: number): Result<number, string> {
if (b === 0) return err("division by zero");
return ok(a / b);
}
A note on the conventional discipline
The contemporary TypeScript generics advice:
- Use single letters (T, U, V) for short, generic-purpose parameters.
- Use descriptive names (Item, Key) for substantial generics.
- Use constraints (
T extends ...) to enable constrained operations. - Use defaults for backward compatibility.
- Trust inference — explicit type arguments are usually unnecessary.
- Use
keyof Tfor type-safe property access. - Use conditional types for type-level computation.
- Use the standard utility types —
Partial,Required,Pick,Omit,ReturnType, etc. - Avoid over-engineering — don’t reach for generics if a concrete type suffices.
The combination — generic functions, classes, types and interfaces, constraints, defaults, conditional types, infer — is the substance of TypeScript’s generic surface. The discipline admits substantial reuse and type-level computation; the inference is good enough that most code avoids the verbosity that other generic systems require.