Most TypeScript codebases use about 20% of what the type system can do. These patterns solve real problems you've probably worked around with any or as.
Discriminated Unions
Model state machines and API responses with perfect type narrowing:
type ApiResult<T> =
| { status: 'success'; data: T }
| { status: 'error'; error: string; code: number }
| { status: 'loading' }
function handleResult<T>(result: ApiResult<T>) {
switch (result.status) {
case 'success':
console.log(result.data) // TypeScript knows data exists
break
case 'error':
console.log(result.error, result.code) // TypeScript knows error/code exist
break
case 'loading':
// result.data doesn't exist here -- TypeScript enforces this
break
}
}
Template Literal Types
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
type ApiRoute = `/api/${string}`
type Endpoint = `${HttpMethod} ${ApiRoute}`
// Valid: "GET /api/users", "POST /api/orders"
// Invalid: "FETCH /api/users" -- TypeScript error
type CSSVariable = `--${string}`
type CssColor = `#${string}` | `rgb(${string})` | `rgba(${string})`
// Extract keys and transform them
type EventNames = 'click' | 'focus' | 'blur'
type EventHandlers = {
[K in EventNames as `on${Capitalize<K>}`]: () => void
}
// Results in: { onClick: () => void; onFocus: () => void; onBlur: () => void }
Conditional Types
// Extract the element type of an array
type ElementOf<T> = T extends (infer E)[] ? E : never
type StringArray = string[]
type Str = ElementOf<StringArray> // string
// Make specific keys required
type RequireKeys<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>
interface Config {
apiUrl?: string
timeout?: number
retries?: number
}
type ValidConfig = RequireKeys<Config, 'apiUrl'>
// { apiUrl: string; timeout?: number; retries?: number }
// Flatten promise types
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T
type Result = Awaited<Promise<Promise<string>>> // string
Branded Types
Prevent mixing up IDs or units at the type level:
declare const __brand: unique symbol
type Brand<T, B> = T & { [__brand]: B }
type UserId = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>
type Dollars = Brand<number, 'Dollars'>
type Cents = Brand<number, 'Cents'>
function makeUserId(id: string): UserId {
return id as UserId
}
function getUser(id: UserId): Promise<User> {
return db.users.findById(id)
}
const orderId = makeOrderId('ord_123')
getUser(orderId) // TypeScript error! OrderId is not assignable to UserId
The Builder Pattern for Type-Safe Query Construction
class QueryBuilder<T extends object, Selected extends keyof T = keyof T> {
private query: Partial<T> = {}
private selectedFields: (keyof T)[] = []
where<K extends keyof T>(key: K, value: T[K]): this {
this.query[key] = value
return this
}
select<K extends keyof T>(...fields: K[]): QueryBuilder<T, K> {
this.selectedFields = fields
return this as any
}
build(): Pick<T, Selected>[] {
return [] as Pick<T, Selected>[] // Implementation would query DB
}
}
interface User {
id: string
email: string
name: string
createdAt: Date
}
const users = new QueryBuilder<User>()
.where('email', 'user@example.com')
.select('id', 'email')
.build()
// users is Pick<User, 'id' | 'email'>[] -- TypeScript knows the shape
users[0].id // OK
users[0].name // TypeScript error! 'name' was not selected
Satisfies Operator
type ColorMap = Record<string, string>
// Old way: loses the literal types
const colors: ColorMap = {
primary: '#FCE38A',
secondary: '#0A0A0A'
}
colors.primary // type: string (not '#FCE38A')
// With satisfies: validates the type but keeps literal types
const colors2 = {
primary: '#FCE38A',
secondary: '#0A0A0A'
} satisfies ColorMap
colors2.primary // type: '#FCE38A' (literal preserved)
colors2.invalid // TypeScript error! (type check still runs)
Exhaustive Checks
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`)
}
type Shape = 'circle' | 'square' | 'triangle'
function getArea(shape: Shape, size: number): number {
switch (shape) {
case 'circle': return Math.PI * size ** 2
case 'square': return size ** 2
case 'triangle': return (size ** 2) / 2
default: return assertNever(shape) // Compile error if you add a new Shape and forget to handle it
}
}
When you add 'pentagon' to Shape, TypeScript immediately flags the switch statement as incomplete. No more runtime bugs from unhandled cases.
These patterns make TypeScript earn its keep. They're not just clever tricks -- they prevent entire categories of bugs at compile time, which is where bugs are cheapest to fix.