Why These Patterns Matter
TypeScript gives you a powerful type system, but most developers only scratch the surface. These patterns will help you express complex business logic at the type level, catching entire classes of bugs before they reach production.
1. Discriminated Unions
The most underused TypeScript feature for state management:
// ❌ Fragile — invalid states are representable
interface State {
isLoading: boolean
data: User | null
error: string | null
}
// ✅ Discriminated union — impossible states are impossible
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; error: string }
function UserProfile({ state }: { state: State }) {
switch (state.status) {
case 'loading':
return <Spinner />
case 'success':
// TypeScript knows data exists here
return <div>{state.data.name}</div>
case 'error':
// And error here
return <ErrorMessage message={state.error} />
case 'idle':
return null
}
}
With the discriminated union, the loading state can never accidentally have data set, and the error state can never accidentally have data. Invalid states become unrepresentable at the type level.
2. Template Literal Types
Build type-safe APIs with string composition:
type EventName = 'click' | 'focus' | 'blur'
type ElementType = 'button' | 'input' | 'form'
// Generates all combinations: 'button:click', 'button:focus', etc.
type EventKey = `${ElementType}:${EventName}`
type EventHandlers = {
[K in EventKey]: (event: Event) => void
}
// Type-safe CSS custom property names
type CSSUnit = 'px' | 'rem' | 'em' | '%' | 'vw' | 'vh'
type SpacingKey = `spacing-${number}${CSSUnit}`
function setSpacing(key: SpacingKey, value: string) {
document.documentElement.style.setProperty(`--${key}`, value)
}
setSpacing('spacing-16px', '16px') // ✅ Valid
setSpacing('spacing-16', '16px') // ❌ Type error — missing unit
3. Conditional Types with Inference
Extract type information dynamically:
// Extract the resolved type from a Promise
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T
// Extract component props type
type ComponentProps<T> = T extends React.ComponentType<infer P> ? P : never
// Make certain keys required while keeping others optional
type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>
// Example usage
interface UserForm {
name?: string
email?: string
role?: 'admin' | 'user'
}
type SubmittedForm = RequireFields<UserForm, 'name' | 'email'>
// name and email are now required, role is still optional
4. Mapped Types with Key Remapping
Transform object shapes entirely at the type level:
// Create getter methods for all properties
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
interface User {
name: string
email: string
age: number
}
type UserGetters = Getters<User>
// Results in:
// {
// getName: () => string
// getEmail: () => string
// getAge: () => number
// }
// Deep readonly — immutable at all nesting levels
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? DeepReadonly<T[K]>
: T[K]
}
const config: DeepReadonly<AppConfig> = loadConfig()
config.database.host = 'new-host' // ❌ Type error — deeply immutable
5. Branded Types for Domain Modeling
Prevent mixing up structurally identical types:
// Without branding — these are interchangeable
type UserId = string
type ProductId = string
function deleteUser(id: UserId) { /* ... */ }
const productId: ProductId = 'prod_123'
deleteUser(productId) // ✅ TypeScript allows this — but it's a bug!
// With branding — they're nominally distinct
type UserId = string & { readonly _brand: 'UserId' }
type ProductId = string & { readonly _brand: 'ProductId' }
function createUserId(id: string): UserId {
return id as UserId
}
function deleteUser(id: UserId) { /* ... */ }
const userId = createUserId('user_456')
const productId = 'prod_123' as ProductId
deleteUser(userId) // ✅ Works
deleteUser(productId) // ❌ Type error — caught at compile time!
6. The satisfies Operator
Validate types without losing specificity:
type Route = {
path: string
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
auth: boolean
}
// ✅ Validates structure AND preserves literal types
const routes = {
users: { path: '/users', method: 'GET', auth: true },
login: { path: '/login', method: 'POST', auth: false },
logout: { path: '/logout', method: 'POST', auth: true },
} satisfies Record<string, Route>
// TypeScript knows this is 'GET', not just string
type UsersMethod = typeof routes.users.method // 'GET'
// Without satisfies, you'd have to annotate and lose the literal
const routesOld: Record<string, Route> = { ... }
type UsersMethodOld = typeof routesOld.users.method // 'GET' | 'POST' | 'PUT' | 'DELETE'
Putting It All Together
These patterns compose naturally. Here's a real-world useApi hook that combines several:
type ApiResponse<T> =
| { status: 'success'; data: T; requestId: string }
| { status: 'error'; code: HttpStatusCode; message: string }
type HttpStatusCode = 400 | 401 | 403 | 404 | 422 | 500
type ApiHook<T> = {
data: T | null
isLoading: boolean
error: string | null
refetch: () => Promise<void>
}
function useApi<T>(url: string): ApiHook<T> {
const [state, dispatch] = useReducer(apiReducer<T>(), { status: 'idle' })
const fetchData = async () => {
dispatch({ type: 'FETCH_START' })
try {
const res = await fetch(url)
const json: ApiResponse<T> = await res.json()
if (json.status === 'success') {
dispatch({ type: 'FETCH_SUCCESS', data: json.data })
} else {
dispatch({ type: 'FETCH_ERROR', message: json.message })
}
} catch (e) {
dispatch({ type: 'FETCH_ERROR', message: 'Network error' })
}
}
return {
data: state.status === 'success' ? state.data : null,
isLoading: state.status === 'loading',
error: state.status === 'error' ? state.message : null,
refetch: fetchData,
}
}
Conclusion
These patterns shift type checking from "catching basic mistakes" to "expressing your domain model precisely." When your types accurately model your domain, entire categories of runtime errors become impossible.
Start with discriminated unions — they'll immediately improve your state management code. Then explore template literal types for your API layer, and branded types for your domain entities.
The investment pays off quickly. Types that model your domain make refactoring safe, documentation automatic, and code reviews focused on business logic rather than correctness.
