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 totalType-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:
- They're invisible in the type signature â callers don't know a function can throw
- They skip intermediate logic â cleanup, logging, and state rollback are easily missed
- They don't compose â you can't
maporflatMapover 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?" /> // â ErrorCo-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.tsAnti-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.tsoutput â downstream packages read declarations, not source
tsc --build --watch # incremental, only recompiles changed packagesInterview Power Moves
-
"I use branded types for every domain primitive â it turns parameter ordering bugs from runtime mysteries into compile-time errors."
-
"The Result pattern makes errors values, not exceptions. They're typed, composable, and force callers to handle failure explicitly."
-
"Zod schemas as single source of truth means runtime validation and TypeScript types can never drift apart â no more
as UserafterJSON.parse." -
"For React component APIs, discriminated union props with
neveron invalid combinations make impossible states truly unrepresentable." -
"I've seen type-level recursion bring
tscto its knees. I add depth limiters on recursive types and prefer interfaces over intersections for performance." -
"Co-locate types with features. A single
types/folder is a God module anti-pattern â it creates implicit coupling between every feature."