Generics in TypeScript
Write reusable, type-safe code with generic functions, classes, constraints, conditional types, and the infer keyword.
Why Generics?
Without generics, a function that works with multiple types must choose between duplicating itself for each type or using any — which throws away all type information. Generics solve this by letting you write a single implementation where the type is a parameter, preserving the relationship between inputs and outputs.
// Returns any — callers lose the specific type and get no IDE help
function wrap(value: any): { value: any } {
return { value };
}
const result = wrap(42);
result.value; // type: any — TypeScript knows nothing about this value
// With generics — the output type mirrors the input type exactly
function wrap<T>(value: T): { value: T } {
return { value };
}
const result = wrap(42);
result.value; // type: number — TypeScript tracked it through the function
Generic Functions
TypeScript infers the type parameter from the argument automatically — you rarely need to write it explicitly. The type parameter T acts as a placeholder that gets filled in at each call site.
function identity<T>(value: T): T {
return value;
}
identity("hello"); // T inferred as string
identity(42); // T inferred as number
identity([1, 2]); // T inferred as number[]
You can always provide the type argument explicitly if inference isn’t working or you want to be more precise:
identity<string>("hello"); // explicit — useful when the argument is ambiguous
A useful generic — returns the last element of any typed array:
function last<T>(arr: T[]): T | undefined {
return arr[arr.length - 1];
}
last([1, 2, 3]); // type: number | undefined
last(["a", "b", "c"]); // type: string | undefined
Multiple Type Parameters
Functions can have multiple generic parameters when the relationship between several types needs to be tracked. Each parameter is inferred independently from the arguments passed.
function pair<A, B>(first: A, second: B): [A, B] {
return [first, second];
}
pair("hello", 42); // [string, number]
pair(true, ["a"]); // [boolean, string[]]
A practical example — a typed object mapper that preserves key types:
function mapObject<K extends string, V, R>(
obj: Record<K, V>,
transform: (value: V, key: K) => R
): Record<K, R> {
const result = {} as Record<K, R>;
for (const key in obj) {
result[key] = transform(obj[key], key);
}
return result;
}
const prices = { apple: 1.5, banana: 0.5 };
const doubled = mapObject(prices, (v) => v * 2);
// { apple: 3, banana: 1 } — typed as Record<"apple" | "banana", number>
Generic Constraints
Without constraints, a type parameter can be anything — you can’t call any methods on it because TypeScript doesn’t know what it is. The extends keyword constrains the parameter to a subset of types, unlocking safe access to specific properties and methods while keeping the function flexible.
// T must have a length property — strings, arrays, and other types qualify
function getLength<T extends { length: number }>(value: T): number {
return value.length;
}
getLength("hello"); // 5 — string has length
getLength([1, 2, 3]); // 3 — array has length
getLength(42); // Error: number doesn't have 'length'
The keyof constraint is particularly powerful — it ensures a key argument is actually valid for the given object:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]; // TypeScript knows the return type is T[K]
}
const user = { name: "Alice", age: 30 };
getProperty(user, "name"); // type: string
getProperty(user, "age"); // type: number
getProperty(user, "email"); // Error: "email" is not a key of this object
Generic Interfaces
Interfaces can be generic too, letting you define reusable contracts that work across different data types. This is how standard library types like Array<T>, Promise<T>, and Map<K, V> are defined.
interface Stack<T> {
push(item: T): void;
pop(): T | undefined;
peek(): T | undefined;
readonly size: number;
}
class ArrayStack<T> implements Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
get size(): number {
return this.items.length;
}
}
const stack = new ArrayStack<number>();
stack.push(1);
stack.push(2);
stack.peek(); // 2
stack.pop(); // 2
Generic Classes
Generic classes carry type information through their entire lifetime. The type is fixed when you construct an instance and flows through every method. This lets you build data structures like repositories, caches, and queues that work safely with any entity type.
// The constraint T extends { id: number } ensures every entity has an id field
class Repository<T extends { id: number }> {
private store = new Map<number, T>();
save(item: T): T {
this.store.set(item.id, item);
return item;
}
findById(id: number): T | undefined {
return this.store.get(id);
}
findAll(): T[] {
return Array.from(this.store.values());
}
delete(id: number): boolean {
return this.store.delete(id);
}
}
interface Post {
id: number;
title: string;
content: string;
}
const posts = new Repository<Post>();
posts.save({ id: 1, title: "Hello", content: "World" });
posts.findById(1)?.title; // "Hello" — typed as string, not any
Default Type Parameters
Generic parameters can have defaults, which are used when the caller doesn’t provide a type argument. This makes generic types ergonomic for the common case while still allowing precise typing when needed.
interface ApiResponse<T = unknown> {
data: T;
status: number;
message: string;
}
// Without a type argument — data is unknown, which forces careful handling
const raw: ApiResponse = { data: "anything", status: 200, message: "OK" };
// With a type argument — data is precisely typed
const typed: ApiResponse<User[]> = { data: [], status: 200, message: "OK" };
Conditional Types
Conditional types let you write type-level logic with a ternary-like syntax. They’re the mechanism behind many of TypeScript’s built-in utility types and let you build types that adapt based on their inputs.
type IsArray<T> = T extends any[] ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<string>; // false
Distributive conditional types: when the checked type is a bare type parameter, the condition distributes over each member of a union separately:
// Distributes over the union — string[] | number[], not (string | number)[]
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// string[] | number[]
To prevent distribution, wrap the type in a tuple — the brackets opt out of the distributive behavior:
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;
type Result = ToArrayNonDist<string | number>;
// (string | number)[]
The infer Keyword
infer lets you capture a piece of a matched type and use it elsewhere in the conditional type. It’s how you extract parts of a type — like the return type of a function, the element type of an array, or the resolved type of a Promise — without having to know the concrete type upfront.
// Extract the return type of any function type
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type A = ReturnType<() => string>; // string
type B = ReturnType<(n: number) => boolean>; // boolean
// Extract the element type from any array type
type ElementType<T> = T extends (infer E)[] ? E : never;
type C = ElementType<string[]>; // string
type D = ElementType<number[][]>; // number[]
// Recursively unwrap nested Promises
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;
type E = Awaited<Promise<string>>; // string
type F = Awaited<Promise<Promise<number>>>; // number
Practical Example: A Type-Safe Event System
This example shows how generics in a class carry type information across the entire API surface. Every event name and its payload type are linked — TypeScript catches mismatches at each on and emit call.
type EventMap = Record<string, unknown>;
class TypedEventEmitter<TEvents extends EventMap> {
private listeners = new Map<
keyof TEvents,
Set<(data: TEvents[keyof TEvents]) => void>
>();
on<K extends keyof TEvents>(
event: K,
listener: (data: TEvents[K]) => void
): this {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(listener as any);
return this; // enables method chaining
}
off<K extends keyof TEvents>(
event: K,
listener: (data: TEvents[K]) => void
): this {
this.listeners.get(event)?.delete(listener as any);
return this;
}
emit<K extends keyof TEvents>(event: K, data: TEvents[K]): void {
this.listeners.get(event)?.forEach((l) => l(data));
}
}
// Define the event map once — all emit/on calls are validated against it
interface AppEvents {
userLoggedIn: { userId: string; timestamp: Date };
orderPlaced: { orderId: string; total: number };
errorOccurred: { message: string; stack?: string };
}
const emitter = new TypedEventEmitter<AppEvents>();
// Handler receives the correctly typed payload — no casting
emitter.on("userLoggedIn", ({ userId, timestamp }) => {
console.log(`User ${userId} logged in at ${timestamp}`);
});
emitter.emit("userLoggedIn", { userId: "123", timestamp: new Date() }); // fine
emitter.emit("userLoggedIn", { wrong: "field" }); // Error — wrong payload shape
emitter.on("unknownEvent", () => {}); // Error — not in AppEvents