Mastering TypeScript Generics — From Basics to Advanced Patterns

A comprehensive guide to TypeScript generics covering type parameters, constraints, conditional types, mapped types, and real-world utility patterns.

TypeScriptProgrammingWeb Dev

Why Generics?

Generics are the backbone of reusable, type-safe code in TypeScript. They let you write functions and types that work with any data type while preserving full type information.

Without generics, you're stuck choosing between type safety and reusability:

// ❌ Type-safe but not reusable
function firstNumber(arr: number[]): number | undefined {
  return arr[0];
}
 
// ❌ Reusable but not type-safe
function firstAny(arr: any[]): any {
  return arr[0];
}
 
// ✅ Both type-safe AND reusable
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}
 
const num = first([1, 2, 3]);       // type: number | undefined
const str = first(["a", "b", "c"]); // type: string | undefined

Constraints with extends

You can restrict what types a generic accepts using the extends keyword:

interface HasLength {
  length: number;
}
 
function logLength<T extends HasLength>(item: T): T {
  console.log(`Length: ${item.length}`);
  return item;
}
 
logLength("hello");     // ✅ strings have length
logLength([1, 2, 3]);   // ✅ arrays have length
logLength(42);          // ❌ numbers don't have length

Conditional Types

Conditional types let you create types that depend on other types:

type ApiResponse<T> = T extends string
  ? { message: T }
  : T extends object
  ? { data: T }
  : { value: T };
 
type A = ApiResponse<string>;  // { message: string }
type B = ApiResponse<User>;    // { data: User }
type C = ApiResponse<number>;  // { value: number }

Real-World Utility: Type-Safe Event Emitter

Here's a practical pattern combining multiple generic features:

type EventMap = {
  userLogin: { userId: string; timestamp: Date };
  pageView: { path: string; referrer?: string };
  error: { code: number; message: string };
};
 
class TypedEmitter<T extends Record<string, unknown>> {
  private handlers = new Map<keyof T, Set<(payload: any) => void>>();
 
  on<K extends keyof T>(event: K, handler: (payload: T[K]) => void) {
    if (!this.handlers.has(event)) {
      this.handlers.set(event, new Set());
    }
    this.handlers.get(event)!.add(handler);
  }
 
  emit<K extends keyof T>(event: K, payload: T[K]) {
    this.handlers.get(event)?.forEach((fn) => fn(payload));
  }
}
 
const emitter = new TypedEmitter<EventMap>();
 
emitter.on("userLogin", ({ userId }) => {
  // userId is typed as string ✅
});
 
emitter.emit("error", { code: 404, message: "Not found" }); // ✅

Summary

ConceptUse Case
Basic <T>Reusable functions/components
extends constraintsRestricting accepted types
Conditional typesType-level branching
Mapped typesTransforming object shapes
inferExtracting types from patterns

Generics are a superpower. Once you internalize these patterns, you'll write TypeScript that's both safer and more expressive.