DNA🔷 TypeScriptTypeScript Patterns at Scale
👑ApexTypeScriptPatternsArchitecture

TypeScript Patterns at Scale

Production-grade TypeScript patterns for large codebases: branded types, type-safe state machines, Result types, Zod validation, polymorphic components, and performance strategies.

TypeScript Patterns at Scale

This is where TypeScript transcends syntax and becomes architecture. These patterns are what you deploy in production systems with dozens of engineers, hundreds of API endpoints, and millions of lines of code. They're the difference between TypeScript that checks boxes and TypeScript that prevents entire categories of bugs.

Branded Types for Domain Modeling

Structural typing is TypeScript's strength — until it isn't. When you have dozens of string parameters flying around, type safety means nothing:

// Dangerous — all strings are interchangeable
function transferMoney(fromAccount: string, toAccount: string, amount: number): void {}
transferMoney(toAccount, fromAccount, amount); // SWAPPED — no error
 
// Safe — branded types prevent parameter swap
type AccountId = string & { readonly __brand: "AccountId" };
type Currency = number & { readonly __brand: "Currency" };
 
function transferMoney(from: AccountId, to: AccountId, amount: Currency): void {}

Generic Brand Factory

declare const __brand: unique symbol;
 
type Brand<T, B> = T & { readonly [__brand]: B };
 
type UserId = Brand<string, "UserId">;
type Email = Brand<string, "Email">;
type NonEmptyString = Brand<string, "NonEmptyString">;
type PositiveInt = Brand<number, "PositiveInt">;
type Percentage = Brand<number, "Percentage">;
 
// Smart constructors — the only way to create branded values
function UserId(id: string): UserId {
  if (!/^usr_[a-z0-9]{12}$/.test(id)) throw new Error("Invalid UserId");
  return id as UserId;
}
 
function Email(email: string): Email {
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new Error("Invalid email");
  return email as Email;
}
 
function PositiveInt(n: number): PositiveInt {
  if (!Number.isInteger(n) || n <= 0) throw new Error("Must be positive integer");
  return n as PositiveInt;
}

The unique symbol approach prevents two different brands from being accidentally compatible — a refinement over the simpler { __brand: string } approach.

Builder Pattern with Type Narrowing

Track which methods have been called through type parameters to prevent invalid construction:

type Flags = { hasUrl: boolean; hasMethod: boolean; hasHeaders: boolean };
 
interface RequestBuilder<F extends Flags = { hasUrl: false; hasMethod: false; hasHeaders: false }> {
  url(url: string): RequestBuilder<F & { hasUrl: true }>;
  method(method: "GET" | "POST" | "PUT" | "DELETE"): RequestBuilder<F & { hasMethod: true }>;
  headers(headers: Record<string, string>): RequestBuilder<F & { hasHeaders: true }>;
  send: F extends { hasUrl: true; hasMethod: true } ? () => Promise<Response> : never;
}
 
function createRequest(): RequestBuilder {
  const config: any = {};
  const builder: any = {
    url: (u: string) => { config.url = u; return builder; },
    method: (m: string) => { config.method = m; return builder; },
    headers: (h: Record<string, string>) => { config.headers = h; return builder; },
    send: () => fetch(config.url, config),
  };
  return builder;
}
 
const res = createRequest()
  .url("https://api.example.com")
  .method("GET")
  .send(); // ✅ — both url and method are set
 
createRequest()
  .url("https://api.example.com")
  .send; // type: never — cannot call without method()

Type-Safe Event Systems (EventMap Pattern)

type EventMap = Record<string, unknown>;
 
interface TypedEventEmitter<Events extends EventMap> {
  on<K extends keyof Events>(
    event: K,
    listener: (payload: Events[K]) => void
  ): () => void;
  emit<K extends keyof Events>(event: K, payload: Events[K]): void;
  once<K extends keyof Events>(
    event: K,
    listener: (payload: Events[K]) => void
  ): void;
}
 
function createEmitter<Events extends EventMap>(): TypedEventEmitter<Events> {
  const listeners = new Map<keyof Events, Set<Function>>();
 
  return {
    on(event, listener) {
      if (!listeners.has(event)) listeners.set(event, new Set());
      listeners.get(event)!.add(listener);
      return () => { listeners.get(event)?.delete(listener); };
    },
    emit(event, payload) {
      listeners.get(event)?.forEach(fn => fn(payload));
    },
    once(event, listener) {
      const unsub = this.on(event, (payload) => {
        unsub();
        listener(payload);
      });
    },
  };
}
 
// Usage
interface AppEvents {
  "user:login": { userId: string; method: "password" | "oauth" };
  "user:logout": { userId: string };
  "cart:update": { items: CartItem[]; total: number };
  "error": { code: string; message: string; fatal: boolean };
}
 
const events = createEmitter<AppEvents>();
events.on("user:login", (data) => {
  console.log(data.userId, data.method); // ✅ fully typed
});
events.emit("cart:update", { items: [], total: 0 }); // ✅
events.emit("cart:update", { items: [] });            // ❌ missing total

Type-Safe State Machines

type StateConfig = Record<string, { on: Record<string, string> }>;
 
type ValidTransition<
  Config extends StateConfig,
  State extends keyof Config,
> = keyof Config[State]["on"];
 
type NextState<
  Config extends StateConfig,
  State extends keyof Config,
  Event extends ValidTransition<Config, State>,
> = Config[State]["on"][Event];
 
function createMachine<Config extends StateConfig>(config: {
  initial: keyof Config;
  states: Config;
}) {
  type State = keyof Config;
  let current: State = config.initial;
 
  return {
    getState(): State {
      return current;
    },
    transition<S extends State, E extends ValidTransition<Config, S>>(
      from: S,
      event: E
    ): NextState<Config, S, E> {
      if (current !== from) throw new Error(`Not in state ${String(from)}`);
      const nextState = config.states[from].on[event as string];
      current = nextState as State;
      return nextState as NextState<Config, S, E>;
    },
  };
}
 
const orderMachine = createMachine({
  initial: "draft" as const,
  states: {
    draft: { on: { submit: "pending", cancel: "cancelled" } },
    pending: { on: { approve: "confirmed", reject: "draft" } },
    confirmed: { on: { ship: "shipped" } },
    shipped: { on: { deliver: "delivered" } },
    delivered: { on: {} },
    cancelled: { on: {} },
  },
});

Result / Either Pattern

Error handling without exceptions — borrowed from Rust and functional programming:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };
 
const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });
 
function parseJSON<T>(raw: string): Result<T, SyntaxError> {
  try {
    return Ok(JSON.parse(raw));
  } catch (e) {
    return Err(e as SyntaxError);
  }
}
 
function divide(a: number, b: number): Result<number, string> {
  if (b === 0) return Err("Division by zero");
  return Ok(a / b);
}
 
// Chaining Results
function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
  return result.ok ? Ok(fn(result.value)) : result;
}
 
function flatMap<T, U, E>(result: Result<T, E>, fn: (value: T) => Result<U, E>): Result<U, E> {
  return result.ok ? fn(result.value) : result;
}
 
// Usage
const result = flatMap(
  parseJSON<{ count: string }>('{"count": "42"}'),
  (data) => divide(100, parseInt(data.count))
);
 
if (result.ok) {
  console.log(result.value); // number
} else {
  console.error(result.error); // SyntaxError | string
}

Why Not Just Throw?

Exceptions have three problems at scale:

  1. They're invisible in the type signature — callers don't know a function can throw
  2. They skip intermediate logic — cleanup, logging, and state rollback are easily missed
  3. They don't compose — you can't map or flatMap over a thrown exception

The Result pattern makes failure a value — explicit, typed, and composable.

Type-Safe API Layer: Zod + TypeScript Inference

Zod bridges the gap between runtime validation and compile-time types:

import { z } from "zod";
 
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  role: z.enum(["admin", "user", "viewer"]),
  createdAt: z.coerce.date(),
});
 
type User = z.infer<typeof UserSchema>;
// { id: string; name: string; email: string; role: "admin" | "user" | "viewer"; createdAt: Date }
 
const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
type CreateUserDTO = z.infer<typeof CreateUserSchema>;
 
async function createUser(input: unknown): Promise<Result<User, z.ZodError>> {
  const parsed = CreateUserSchema.safeParse(input);
  if (!parsed.success) return Err(parsed.error);
  // parsed.data is fully typed as CreateUserDTO
  const user = await db.users.create(parsed.data);
  return Ok(user);
}

The schema is the single source of truth — runtime validation and TypeScript types derived from the same definition. No drift, no duplication.

Generic React Component Patterns

Polymorphic as Prop

type PolymorphicProps<E extends React.ElementType, P = {}> = P &
  Omit<React.ComponentPropsWithRef<E>, keyof P | "as"> & {
    as?: E;
  };
 
type ButtonProps<E extends React.ElementType = "button"> = PolymorphicProps<
  E,
  { variant: "primary" | "secondary"; size: "sm" | "md" | "lg" }
>;
 
function Button<E extends React.ElementType = "button">({
  as,
  variant,
  size,
  ...props
}: ButtonProps<E>) {
  const Component = as ?? "button";
  return <Component {...props} className={`btn-${variant} btn-${size}`} />;
}
 
// Usage
<Button variant="primary" size="md" onClick={() => {}} />        // renders <button>
<Button as="a" variant="secondary" size="lg" href="/about" />    // renders <a> with href
<Button as="a" variant="primary" size="sm" onClick={() => {}} /> // ✅ <a> supports onClick
<Button as="a" variant="primary" size="sm" type="submit" />      // ❌ <a> doesn't have type="submit"

Generic List Component

interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string;
  emptyState?: React.ReactNode;
  className?: string;
}
 
function List<T>({ items, renderItem, keyExtractor, emptyState, className }: ListProps<T>) {
  if (items.length === 0 && emptyState) return <>{emptyState}</>;
  return (
    <ul className={className}>
      {items.map((item, i) => (
        <li key={keyExtractor(item)}>{renderItem(item, i)}</li>
      ))}
    </ul>
  );
}
 
// Usage — T is inferred from items
<List
  items={users}
  keyExtractor={(user) => user.id}
  renderItem={(user) => <span>{user.name}</span>}
/>

Strict Discriminated Union Props

type ModalProps =
  | { variant: "confirm"; onConfirm: () => void; onCancel: () => void; message: string }
  | { variant: "alert"; onDismiss: () => void; message: string }
  | { variant: "prompt"; onSubmit: (value: string) => void; onCancel: () => void; label: string };
 
function Modal(props: ModalProps) {
  switch (props.variant) {
    case "confirm":
      return (
        <div>
          <p>{props.message}</p>
          <button onClick={props.onConfirm}>OK</button>
          <button onClick={props.onCancel}>Cancel</button>
        </div>
      );
    case "alert":
      return (
        <div>
          <p>{props.message}</p>
          <button onClick={props.onDismiss}>OK</button>
        </div>
      );
    case "prompt":
      // props.label and props.onSubmit available here
      return <div>{/* prompt UI */}</div>;
  }
}
 
// ✅ TypeScript enforces the correct props for each variant
<Modal variant="confirm" onConfirm={() => {}} onCancel={() => {}} message="Sure?" />
<Modal variant="alert" onDismiss={() => {}} message="Done!" />
<Modal variant="confirm" onDismiss={() => {}} message="Sure?" /> // ❌ Error

Co-location of Types

Types should live next to the code that uses them:

src/
├── features/
│   ├── auth/
│   │   ├── types.ts          ← auth-specific types
│   │   ├── auth.service.ts
│   │   ├── auth.hooks.ts
│   │   └── auth.utils.ts
│   ├── users/
│   │   ├── types.ts          ← user-specific types
│   │   └── ...
├── shared/
│   ├── types/                ← truly shared types
│   │   ├── api.ts
│   │   ├── branded.ts
│   │   └── utility.ts

Anti-pattern: A single types/ folder at the root with hundreds of type definitions. This creates a God module that everything depends on and nobody owns.

Monorepo Type Sharing

// packages/shared-types/src/index.ts
export type { User, UserRole } from "./user";
export type { APIResponse, PaginatedResponse } from "./api";
export type { Brand } from "./branded";
 
// packages/frontend/src/features/users.ts
import type { User, PaginatedResponse } from "@myorg/shared-types";
 
// packages/backend/src/routes/users.ts
import type { User, APIResponse } from "@myorg/shared-types";

Use composite: true and project references so TypeScript builds shared types before dependent packages. Use declaration: true and declarationMap: true for go-to-definition support across package boundaries.

Performance Tips

Avoiding Deep Type Recursion

// ❌ This can blow up the compiler on deep objects
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
 
// ✅ Add a depth limiter
type DeepPartial<T, Depth extends number[] = []> =
  Depth["length"] extends 10
    ? T
    : {
        [K in keyof T]?: T[K] extends object
          ? DeepPartial<T[K], [...Depth, 0]>
          : T[K];
      };

Interface vs Type for Performance

Interfaces are faster to check than intersections:

// ✅ Faster — single named type
interface UserWithTimestamps extends User, Timestamps {}
 
// ❌ Slower — intersection computed on every use
type UserWithTimestamps = User & Timestamps;

For public APIs with many consumers, prefer interfaces. The type checker caches interface relationships but must re-compute intersections.

Project References for Speed

In a monorepo with 50+ packages, tsc checking the entire codebase from root is painfully slow. Project references with --build mode enable:

  • Incremental compilation — only recheck changed packages
  • Parallel checking — independent packages check concurrently
  • Cached .d.ts output — downstream packages read declarations, not source
tsc --build --watch   # incremental, only recompiles changed packages

Interview Power Moves

  1. "I use branded types for every domain primitive — it turns parameter ordering bugs from runtime mysteries into compile-time errors."

  2. "The Result pattern makes errors values, not exceptions. They're typed, composable, and force callers to handle failure explicitly."

  3. "Zod schemas as single source of truth means runtime validation and TypeScript types can never drift apart — no more as User after JSON.parse."

  4. "For React component APIs, discriminated union props with never on invalid combinations make impossible states truly unrepresentable."

  5. "I've seen type-level recursion bring tsc to its knees. I add depth limiters on recursive types and prefer interfaces over intersections for performance."

  6. "Co-locate types with features. A single types/ folder is a God module anti-pattern — it creates implicit coupling between every feature."