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}`
}
}
This is the foundation of type-safe state management. Every React app with async data should use this pattern instead of separate isLoading, isError, data booleans.
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 createUserId(id: string): UserId {
return id as UserId
}
function getOrder(orderId: OrderId) { /* ... */ }
const userId = createUserId("user_123")
// getOrder(userId) // Compile error! Can't pass UserId where OrderId expected
Zero runtime cost. The brand exists only in the type system. Use this for any ID type, currency amount, or validated string.
Template Literal Types
Generate string unions from combinations:
type EventName = `${"user" | "order"}.${"created" | "updated" | "deleted"}`
// Result: "user.created" | "user.updated" | "user.deleted" | "order.created" | "order.updated" | "order.deleted"
type CSSProperty = `${string}-${string}`
type HexColor = `#${string}`
This is powerful for event systems, CSS-in-JS, and any API where string patterns matter.
Result Types for Error Handling
Stop throwing exceptions. Return typed results instead:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E }
function parseConfig(input: string): Result<Config, ParseError> {
try {
const config = JSON.parse(input)
return { ok: true, value: config }
} catch (e) {
return { ok: false, error: new ParseError(e.message) }
}
}
const result = parseConfig(input)
if (result.ok) {
console.log(result.value) // TypeScript knows this is Config
} else {
console.error(result.error) // TypeScript knows this is ParseError
}
The caller is forced to handle both cases. No more uncaught exceptions in production.
Exhaustive Matching
Use the never type to ensure you handle every case in a union:
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`)
}
function handleStatus(status: AsyncState<unknown>) {
switch (status.status) {
case "idle": return "Ready"
case "loading": return "Loading..."
case "success": return "Done"
case "error": return "Failed"
default: return assertNever(status) // Compile error if a case is missing
}
}
When you add a new variant to the union, TypeScript will flag every switch statement that doesn't handle it.
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.
These patterns have zero runtime overhead. They exist purely in the type system, disappearing completely after compilation. That's the beauty of TypeScript done right.
Want more dev content? Check out the blog or buy me a coffee.
Top comments (0)