TypeScript has matured far beyond “JavaScript with types.” The type system is powerful enough to encode complex business logic at compile time. Here are the patterns that make the biggest difference in production codebases.
Discriminated Unions
Model states where different variants carry different data. TypeScript narrows the type automatically based on the discriminant.
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error }
function renderState<T>(state: AsyncState<T>) {
switch (state.status) {
case "idle": return "Ready"
case "loading": return "Loading..."
case "success": return `Data: ${JSON.stringify(state.data)}`
case "error": return `Error: ${state.error.message}`
}
}Branded Types
Prevent mixing up values that are the same primitive type but represent different things.
type UserId = string & { readonly __brand: "UserId" }
type OrderId = string & { readonly __brand: "OrderId" }
function getOrder(orderId: OrderId) { /* ... */ }
const userId = "user_123" as UserId
getOrder(userId) // Compile error!Template Literal Types
type EventName = `${"user" | "order"}.${"created" | "updated" | "deleted"}`
// "user.created" | "user.updated" | "user.deleted" | "order.created" | ...Result Types for Error Handling
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E }
const result = parseConfig(input)
if (result.ok) console.log(result.value)
else console.error(result.error)Wrapping Up
Start with discriminated unions and exhaustive matching — they change how you think about state management. Layer in branded types and Result types as your codebase grows. The goal: make illegal states unrepresentable.