TypeScript by Example

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

Generic 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()); // 2

The 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