Generics
Type parameters, generic functions and interfaces, constraints with extends, and default type parameters.
Generics let you write code that works across types while preserving type safety. A generic function says "I'll take a value of type T and give back a value of type T" - the caller determines what T is.
A generic function accepts a type parameter in angle brackets. TypeScript infers the type argument from the value you pass - you rarely need to specify it explicitly.
function identity<T>(value: T): T {
return value;
}
const s = identity("hello"); // inferred: string
const n = identity(42); // inferred: number
// Explicit type argument when inference isn't possible
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const head = first([1, 2, 3]); // inferred: number | undefinedGeneric interfaces describe structures that work with any type. A Stack<T> can hold numbers, strings, or any other type - the interface enforces consistency.
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);
console.log(stack.peek()); // 2The extends keyword constrains a type parameter. The function now only accepts values that satisfy the constraint - TypeScript can safely access constrained properties inside the body.
// Without constraint: TypeScript doesn't know T has .length
// function longest<T>(a: T, b: T): T { return a.length > b.length ? a : b; }
// With constraint: T must have a length property
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
console.log(longest("short", "longer")); // "longer"
console.log(longest([1, 2], [3, 4, 5])); // [3, 4, 5]
// Works on any type with .length - strings, arrays, typed arrays
function withId<T extends object>(item: T, id: string): T & { id: string } {
return { ...item, id };
}
const user = withId({ name: "Ada" }, "user-1");
// user: { name: string; id: string }Default type parameters let callers omit the type argument when a sensible default exists - similar to default function parameters.
interface ApiResponse<T = unknown> {
data: T;
status: number;
message: string;
}
// No type argument needed - defaults to unknown
const raw: ApiResponse = { data: null, status: 200, message: "OK" };
// Explicit type argument for typed responses
const typed: ApiResponse<{ id: string; name: string }> = {
data: { id: "1", name: "Ada" },
status: 200,
message: "OK",
};In production
Unbounded generics (<T>) accept any value, which pushes validation into the implementation and often forces unsafe casts. Constrain early: <T extends object>, <T extends { id: string }>, or <T extends keyof SomeType>. Understanding generics lets you read and write the standard utility types (Partial<T>, Required<T>, Record<K, V>, ReturnType<F>) instead of treating them as magic - they're all built on the same <T extends ...> mechanism. For REST API clients, a single generic fetch<T>(url: string): Promise<T> wrapper with a runtime Zod parse is the cleanest pattern: the generic provides IDE autocompletion; the parse catches schema drift before it propagates through the app.
Enjoyed this? Get more essays on software craft delivered to your inbox.
Subscribe free