TypeScript Beyond the Basics
Most TypeScript codebases use maybe 30% of what the type system can do. They use interfaces and basic generics, but miss the patterns that eliminate entire categories of runtime bugs at compile time. These advanced patterns are not academic — they solve real problems that appear in production codebases and become valuable as teams and codebases grow.
Branded Types for Domain Safety
A UserId and an OrderId are both strings, but they are not interchangeable. Passing a UserId where an OrderId is expected is a logic bug that TypeScript will not catch without branded types.
type Brand = T & { readonly __brand: B }
type UserId = Brand
type OrderId = Brand
type Email = Brand
// Constructor functions that validate at the boundary
function toUserId(id: string): UserId {
if (!id.startsWith('usr_')) throw new Error('Invalid UserId')
return id as UserId
}
function getOrder(userId: UserId, orderId: OrderId) {
// TypeScript enforces correct argument order
}
const uid = toUserId('usr_123')
const oid = 'ord_456' as OrderId
getOrder(uid, oid) // ✓
getOrder(oid, uid) // ✗ TypeScript error — types are incompatible
Discriminated Unions for State Machines
Use discriminated unions to model states that have different data shapes. TypeScript narrows the type in each branch, eliminating the need for runtime checks and preventing access to fields that don't exist in a given state.
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T; timestamp: number }
| { status: 'error'; error: Error; retryCount: number }
function renderRequest(state: RequestState) {
switch (state.status) {
case 'idle':
return null
case 'loading':
return
case 'success':
// TypeScript knows state.data and state.timestamp exist here
return
case 'error':
// TypeScript knows state.error and state.retryCount exist here
return
}
}
Type Guards for Runtime Validation
Type guards let you validate data at runtime boundaries (API responses, user input, JSON.parse) and carry that validation into the type system.
interface UserProfile {
id: string
name: string
email: string
role: 'admin' | 'user'
}
function isUserProfile(value: unknown): value is UserProfile {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as any).id === 'string' &&
typeof (value as any).name === 'string' &&
typeof (value as any).email === 'string' &&
['admin', 'user'].includes((value as any).role)
)
}
// At API boundary
const data = await response.json()
if (!isUserProfile(data)) {
throw new Error('Invalid user profile response')
}
// From here, TypeScript knows data is UserProfile
console.log(data.role) // typed as 'admin' | 'user'
For production codebases with many API boundaries, use a validation library like Zod instead of hand-written type guards. Zod infers TypeScript types from schemas, keeping validation and types in sync automatically.
The satisfies Operator
The satisfies operator (TypeScript 4.9+) validates that a value matches a type without widening it. This is useful when you want type checking but also want to preserve the literal types of values.
type Config = {
database: { url: string; maxConnections: number }
redis: { url: string; ttl: number }
features: Record
}
// Without satisfies: config is typed as Config — literals are widened
const config: Config = {
database: { url: 'postgres://...', maxConnections: 10 },
redis: { url: 'redis://...', ttl: 300 },
features: { newCheckout: true, darkMode: false },
}
// With satisfies: validated against Config but types are preserved
const config = {
database: { url: 'postgres://...', maxConnections: 10 },
redis: { url: 'redis://...', ttl: 300 },
features: { newCheckout: true, darkMode: false },
} satisfies Config
// config.features.newCheckout is typed as true (literal), not boolean
// config.database.maxConnections is typed as number
// Any missing field or wrong type in the literal causes a compile error
Conditional Types for Library Code
// Extract the return type of an async function
type Awaited = T extends Promise ? U : T
// Make specific keys required
type WithRequired = T & Required>
// Deep partial for patch operations
type DeepPartial = {
[P in keyof T]?: T[P] extends object ? DeepPartial : T[P]
}
// Usage
type UserUpdate = DeepPartial
type CreateOrderInput = WithRequired
These utility types become invaluable as your codebase grows. Define them once in a shared types package and use them across your entire monorepo.