TypeScript Patterns I Actually Use Every Day
Practical TypeScript patterns that go beyond the basics — discriminated unions, branded types, the builder pattern, and more. No academic exercises, just patterns I reach for in real codebases.
I’ve been writing TypeScript professionally for five years now, and over that time I’ve developed strong opinions about which patterns are genuinely useful versus which ones are clever but impractical. Here are the patterns I reach for almost daily.
Discriminated Unions for State Machines
This is the single most impactful TypeScript pattern I know. Instead of modelling state with a bag of optional fields, use a discriminated union to make illegal states unrepresentable.
Here’s the classic example — an API request lifecycle:
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function renderUserProfile(state: RequestState<User>) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <Spinner />;
case "success":
// TypeScript knows `data` exists here
return <Profile user={state.data} />;
case "error":
// TypeScript knows `error` exists here
return <ErrorMessage message={state.error.message} />;
}
}
The power here is that you literally cannot access state.data when the status is "loading" — the compiler prevents it. Compare this to the alternative:
// Don't do this
interface RequestState<T> {
loading: boolean;
data?: T;
error?: Error;
}
// Now you can have { loading: true, data: someValue, error: someError }
// That state makes no sense, but TypeScript allows it.
I use this pattern for form states, WebSocket connection states, authentication flows — anywhere there’s a clear state machine.
Branded Types for Semantic Safety
TypeScript’s structural typing is great, but sometimes you want nominal semantics. Branded types let you distinguish between values that are structurally identical but semantically different.
type Brand<T, B extends string> = T & { __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
// Constructor functions that validate
function UserId(id: string): UserId {
if (!id.startsWith("usr_")) {
throw new Error(`Invalid user ID: ${id}`);
}
return id as UserId;
}
function Email(value: string): Email {
if (!value.includes("@")) {
throw new Error(`Invalid email: ${value}`);
}
return value as Email;
}
// Now these are different types
function getUser(id: UserId): Promise<User> { /* ... */ }
function getOrder(id: OrderId): Promise<Order> { /* ... */ }
const userId = UserId("usr_abc123");
const orderId = OrderId("ord_xyz789");
getUser(userId); // ✅ works
getUser(orderId); // ❌ compile error — OrderId is not UserId
The __brand property never exists at runtime — it’s purely a compile-time marker. The cost is zero, but the safety is real. I’ve caught bugs with this pattern that would have been subtle runtime issues.
The satisfies Operator
satisfies landed in TypeScript 4.9 and I use it constantly. It lets you validate that a value matches a type without widening it.
type Route = {
path: string;
method: "GET" | "POST" | "PUT" | "DELETE";
handler: (req: Request) => Response;
};
// With `as const` alone, you lose type checking
// With a type annotation, you lose literal types
// `satisfies` gives you both
const routes = [
{
path: "/api/users",
method: "GET",
handler: handleGetUsers,
},
{
path: "/api/users",
method: "POST",
handler: handleCreateUser,
},
] as const satisfies readonly Route[];
// routes[0].method is "GET" (literal), not string
// But you still get an error if you typo "GETT"
This is especially useful for configuration objects where you want both type safety and precise literal types for downstream inference.
The Builder Pattern with Method Chaining
For complex object construction, a fluent builder gives you great ergonomics with full type safety. The trick is using generics to track which fields have been set.
type QueryConfig = {
table: string;
columns: string[];
where?: Record<string, unknown>;
orderBy?: { column: string; direction: "asc" | "desc" };
limit?: number;
};
class QueryBuilder {
private config: Partial<QueryConfig> = {};
from(table: string): this {
this.config.table = table;
return this;
}
select(...columns: string[]): this {
this.config.columns = columns;
return this;
}
where(conditions: Record<string, unknown>): this {
this.config.where = conditions;
return this;
}
orderBy(column: string, direction: "asc" | "desc" = "asc"): this {
this.config.orderBy = { column, direction };
return this;
}
limit(n: number): this {
this.config.limit = n;
return this;
}
build(): QueryConfig {
if (!this.config.table) throw new Error("table is required");
if (!this.config.columns?.length) throw new Error("columns are required");
return this.config as QueryConfig;
}
}
// Clean, readable API
const query = new QueryBuilder()
.from("users")
.select("id", "name", "email")
.where({ active: true })
.orderBy("name", "asc")
.limit(50)
.build();
Record and Mapped Types for Exhaustive Handling
When you need to ensure every variant of a union is handled, a mapped type is your friend:
type LogLevel = "debug" | "info" | "warn" | "error";
const logColors: Record<LogLevel, string> = {
debug: "#6b7280",
info: "#3b82f6",
warn: "#f59e0b",
error: "#ef4444",
};
// If you add a new LogLevel variant, TypeScript will force you
// to add an entry here too. No silent fallthrough.
I combine this with discriminated unions for exhaustive switch statements using a never check:
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}`);
}
function handleEvent(event: AppEvent) {
switch (event.type) {
case "user_login":
return trackLogin(event);
case "page_view":
return trackPageView(event);
case "purchase":
return trackPurchase(event);
default:
// If you add a new event type and forget to handle it,
// this line will have a compile error
return assertNever(event);
}
}
Function Overloads for Clean Public APIs
When a function has different return types depending on its input, overloads make the API honest:
function parse(input: string, format: "json"): object;
function parse(input: string, format: "csv"): string[][];
function parse(input: string, format: "json" | "csv"): object | string[][] {
if (format === "json") {
return JSON.parse(input);
}
return input.split("\n").map((row) => row.split(","));
}
const data = parse(rawInput, "json");
// TypeScript knows `data` is `object`, not `object | string[][]`
Wrapping Up
These patterns share a common thread: they push errors from runtime to compile time. Every bug the compiler catches is a bug that never reaches production. TypeScript is at its best when you lean into the type system rather than fighting it with any and type assertions.
The patterns I’ve intentionally left out — like conditional types, template literal types, and recursive type inference — are powerful but rarely needed in application code. Save those for library authors. For day-to-day work, the patterns above will cover 95% of your needs.