WritingTypeScript Generics: Advanced Patterns for Production Codebases — LinkIT
4 min readtypescript, generics, advanced, type-safety, patterns

TypeScript Generics: Advanced Patterns for Production Codebases

TypeScript generic examples for keyed utilities, API response types, conditional and mapped types, and event payloads.

Generics can express relationships between input types, response envelopes, event names, and payloads. The examples below cover constraints, conditional types, mapped types, and typed events, followed by cases where a concrete type is simpler.

Generic Patterns

Constrained Generics for Utility Functions

The extends keyword in a type parameter is a constraint, not inheritance. It means "T must be assignable to this shape."

function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
 
const user = { name: "Alice", age: 30 };
const name = pluck(user, "name"); // string — correct
const age = pluck(user, "age");   // number — correct
// pluck(user, "email")           // compile error — correct

Without the K extends keyof T constraint, key would be typed as string and TypeScript could not infer the return type accurately. The constraint is what enables the precise inference.

Generic Interfaces for API Responses

Define your response envelope once and reuse it across every endpoint:

interface ApiResponse<T> {
  data: T;
  meta: {
    total: number;
    page: number;
    pageSize: number;
  };
  error: null;
}
 
interface ApiError {
  data: null;
  error: {
    code: string;
    message: string;
  };
}
 
type Result<T> = ApiResponse<T> | ApiError;

Callers receive Result<User[]> or Result<Order>. TypeScript narrows the type when they check if (result.error !== null). No casting required.

Conditional Types for Transformation Utilities

Conditional types let you branch on type-level conditions:

type NonNullable<T> = T extends null | undefined ? never : T;
 
type Flatten<T> = T extends Array<infer U> ? U : T;
 
type Flatten_string = Flatten<string[]>;   // string
type Flatten_number = Flatten<number>;     // number (not an array, returns as-is)

The infer keyword inside a conditional type captures a type variable within the match. This is how the standard library's ReturnType<T> and Parameters<T> are implemented.

Mapped Types for Property Transformations

Mapped types iterate over the keys of a type and transform each property:

type Partial<T> = {
  [K in keyof T]?: T[K];
};
 
type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};
 
// Practical: make all nested properties optional
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

DeepPartial is useful for update payloads where callers send only the fields they want to change. Without it, you end up with Partial<User> which does not recurse into nested objects.

Typed Event Emitters

A typed event emitter links each event name to its payload:

type EventMap = {
  "user:created": { id: string; email: string };
  "order:shipped": { orderId: string; trackingNumber: string };
  "payment:failed": { orderId: string; reason: string };
};
 
class TypedEmitter<Events extends Record<string, unknown>> {
  private listeners = new Map<keyof Events, Set<(payload: unknown) => void>>();
 
  on<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(handler as (payload: unknown) => void);
  }
 
  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    this.listeners.get(event)?.forEach(handler => handler(payload));
  }
}
 
const emitter = new TypedEmitter<EventMap>();
 
emitter.on("user:created", ({ id, email }) => {
  // id and email are correctly typed — no casting
});
 
emitter.emit("order:shipped", {
  orderId: "123",
  trackingNumber: "TRK456"
});
// emitter.emit("order:shipped", { orderId: "123" }) — compile error: missing trackingNumber

If you rename an event or change its payload shape, every broken handler is flagged at compile time. This is the kind of safety that pays off in a large codebase with many event producers and consumers.

When Not to Use Generics

A concrete type may be sufficient when a function does not need to support several related types. Generics add cognitive overhead and can make error messages harder to read.

Ask yourself: "Does this function need to work with multiple different types?" If the answer is always the same type, use that type directly. If you are fighting TypeScript to express what you want in a generic, a simpler approach almost certainly exists.

Start with one type parameter. Add a second only when you need to model a relationship between two types (like K extends keyof T). Add conditional types and mapped types only when simpler patterns cannot express the constraint.


For help reviewing TypeScript interfaces and generic utilities, contact LinkIT.