TS

Common Patterns

TypeScript · 7 entries

Type-Safe Event Emitter

syntax
interface Events {
  eventName: (payload: Type) => void;
}
class Emitter<T extends Record<string, (...args: any[]) => void>> { ... }
example
type EventMap = {
  userLogin: (userId: string, timestamp: Date) => void;
  purchase: (orderId: string, amount: number) => void;
  error: (error: Error) => void;
};

class TypedEmitter<T extends Record<string, (...args: any[]) => void>> {
  private handlers = new Map<keyof T, Set<Function>>();

  on<K extends keyof T>(event: K, handler: 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, ...args: Parameters<T[K]>): void {
    this.handlers.get(event)?.forEach(fn => fn(...args));
  }
}

const bus = new TypedEmitter<EventMap>();
bus.on("purchase", (orderId, amount) => {
  console.log(`Order ${orderId}: $${amount}`);
});
bus.emit("purchase", "ord_1", 49.99);
output
// Full autocomplete on event names, parameter types, and callback signatures

Note This pattern maps event names to callback signatures. Parameters<T[K]> extracts the argument tuple so emit() is fully typed. This is the foundation used by libraries like mitt, EventEmitter3, and socket.io.

Type-Safe Builder Pattern

syntax
class Builder<T> {
  set<K extends keyof T>(key: K, value: T[K]): this { ... }
  build(): T { ... }
}
example
interface EmailMessage {
  to: string;
  subject: string;
  body: string;
  cc?: string[];
  priority?: "low" | "normal" | "high";
}

class EmailBuilder {
  private message: Partial<EmailMessage> = {};

  to(address: string): this {
    this.message.to = address;
    return this;
  }
  subject(text: string): this {
    this.message.subject = text;
    return this;
  }
  body(html: string): this {
    this.message.body = html;
    return this;
  }
  cc(addresses: string[]): this {
    this.message.cc = addresses;
    return this;
  }
  priority(level: EmailMessage["priority"]): this {
    this.message.priority = level;
    return this;
  }
  build(): EmailMessage {
    if (!this.message.to || !this.message.subject || !this.message.body) {
      throw new Error("to, subject, and body are required");
    }
    return this.message as EmailMessage;
  }
}

const email = new EmailBuilder()
  .to("dev@example.com")
  .subject("Deploy complete")
  .body("<p>All checks passed.</p>")
  .priority("high")
  .build();
output
// Fluent API with full type safety and autocomplete at each step

Note Returning 'this' instead of the class name enables proper chaining even in subclasses. For compile-time enforcement of required fields (instead of runtime throws), use a step builder pattern with generics that track which fields have been set.

Exhaustive Switch / If-Else

syntax
function assertNever(value: never): never {
  throw new Error(`Unexpected: ${value}`);
}
example
type PaymentMethod = "card" | "bank" | "crypto" | "paypal";

function processPayment(method: PaymentMethod): string {
  switch (method) {
    case "card":
      return "Processing card payment";
    case "bank":
      return "Processing bank transfer";
    case "crypto":
      return "Processing crypto payment";
    case "paypal":
      return "Processing PayPal";
    default:
      // If a new member is added to PaymentMethod,
      // this line causes a compile error
      const _exhaustive: never = method;
      throw new Error(`Unknown method: ${_exhaustive}`);
  }
}
output
// Adding a fifth payment method causes a compile error at the never assignment

Note The never trick ensures you handle every union member. When a new member is added, TypeScript sees it is not handled and cannot assign it to never. This is critical for state machines, reducers, and any logic that must cover all cases. Some teams extract this into a shared assertNever utility function.

const Assertions

syntax
const value = expression as const;
example
// Without as const: types are widened
const config = { apiUrl: "https://api.example.com", retries: 3 };
// { apiUrl: string; retries: number }

// With as const: literal types + readonly
const frozenConfig = {
  apiUrl: "https://api.example.com",
  retries: 3,
  features: ["search", "export"],
} as const;
// { readonly apiUrl: "https://api.example.com"; readonly retries: 3; readonly features: readonly ["search", "export"] }

// Deriving a union type from const values
const ROLES = ["admin", "editor", "viewer"] as const;
type Role = (typeof ROLES)[number];
// "admin" | "editor" | "viewer"
output
// as const freezes the type to its most specific literal form

Note as const makes all properties readonly and infers the narrowest possible literal types. It applies recursively to nested objects and arrays. This is the idiomatic way to derive string literal unions from runtime arrays, replacing enums in many cases.

Result Type Pattern

syntax
type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };
example
type Result<T, E = string> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function parseAge(input: string): Result<number> {
  const num = parseInt(input, 10);
  if (isNaN(num)) return { ok: false, error: "Not a valid number" };
  if (num < 0 || num > 150) return { ok: false, error: "Age out of range" };
  return { ok: true, value: num };
}

const result = parseAge("25");
if (result.ok) {
  console.log(`Age: ${result.value}`); // narrowed to { ok: true; value: number }
} else {
  console.log(`Invalid: ${result.error}`); // narrowed to { ok: false; error: string }
}
output
// Discriminated union forces callers to handle both success and failure

Note The Result pattern replaces throwing exceptions with explicit return types. Callers cannot forget to handle the error case because TypeScript requires narrowing before accessing .value or .error. This is inspired by Rust's Result<T, E> type.

Type-Safe Runtime Validation

syntax
// Using infer with validation libraries
type Inferred = z.infer<typeof schema>;
example
// Zod-style schema → derived type (no duplication)
import { z } from "zod";

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(),
  role: z.enum(["admin", "user", "guest"]),
});

// Derive the TypeScript type from the schema
type User = z.infer<typeof UserSchema>;
// { id: string; name: string; email: string; age?: number; role: "admin" | "user" | "guest" }

function createUser(raw: unknown): User {
  return UserSchema.parse(raw); // throws if invalid
}
output
// Single schema defines both runtime validation and compile-time type

Note Libraries like Zod, Valibot, and ArkType let you define a schema once and derive the TypeScript type from it. This eliminates the common problem of types and validation getting out of sync. z.infer<typeof schema> is a powerful pattern that keeps your types as the single source of truth.

Type-Safe Action / Reducer Pattern

syntax
type Action = { type: "name"; payload: Type } | ...;
function reducer(state: State, action: Action): State { ... }
example
interface AppState {
  count: number;
  message: string;
}

type AppAction =
  | { type: "increment"; amount: number }
  | { type: "decrement"; amount: number }
  | { type: "setMessage"; message: string }
  | { type: "reset" };

function reducer(state: AppState, action: AppAction): AppState {
  switch (action.type) {
    case "increment":
      return { ...state, count: state.count + action.amount };
    case "decrement":
      return { ...state, count: state.count - action.amount };
    case "setMessage":
      return { ...state, message: action.message };
    case "reset":
      return { count: 0, message: "" };
  }
}
output
// Each case narrows the action type, giving access to case-specific fields

Note This is the standard pattern for Redux, useReducer, and state machines. The 'type' property serves as the discriminant. TypeScript narrows automatically in each case, so action.amount is only available in increment/decrement cases. Add a default never check for exhaustiveness.