TS Patterns
Targeting TypeScript 5.x
Syntax
Utility Types
interface User {
id: number
name: string
email: string
}
Partial<User> // all optional
Required<User> // all required
Pick<User, 'id' | 'name'> // subset
Omit<User, 'email'> // exclude
Readonly<User> // immutable
Record<string, number> // { [key]: num }Extract & Exclude
type Status = 'ok' | 'error' | 'loading' | 'idle'
// keep only members assignable to union
type Active = Extract<Status, 'ok' | 'loading'>
// 'ok' | 'loading'
// remove members assignable to union
type Inactive = Exclude<Status, 'ok' | 'loading'>
// 'error' | 'idle'
// works with object types too
type Fn = Extract<string | (() => void), Function>
// () => voidOverloads
function format(val: string): string
function format(val: number): string
function format(val: string | number): string {
if (typeof val === 'string') return val.trim()
return val.toFixed(2)
}
format('hello') // string signature
format(3.14159) // number signatureBeginner Patterns
Discriminated Union
type Result =
| { status: 'ok'; data: string }
| { status: 'error'; message: string }
function handle(r: Result) {
switch (r.status) {
case 'ok':
r.data // TS knows data exists
break
case 'error':
r.message // TS knows message exists
break
}
}Type Guards
interface Dog { bark(): void }
interface Cat { meow(): void }
// custom guard — returns `is` type
function isDog(pet: Dog | Cat): pet is Dog {
return 'bark' in pet
}
const pet: Dog | Cat = getPet()
if (isDog(pet)) {
pet.bark() // TS knows it's Dog
}as const
const routes = ['/', '/about', '/blog'] as const
// readonly ['/', '/about', '/blog']
type Route = (typeof routes)[number]
// '/' | '/about' | '/blog'
const config = { env: 'prod', port: 3500 } as const
// { readonly env: 'prod'; readonly port: 3500 }Satisfies
type Colors = Record<string, string | string[]>
// validates type without widening
const palette = {
red: '#ff0000',
blue: ['#0000ff', '#0033cc'],
} satisfies Colors
// TS still knows red is string, blue is string[]
palette.red.toUpperCase() // works
palette.blue.map(c => c) // worksIntermediate Patterns
Branded / Opaque Types
// prevent mixing structurally identical types
type USD = number & { __brand: 'USD' }
type EUR = number & { __brand: 'EUR' }
function usd(amount: number): USD {
return amount as USD
}
function eur(amount: number): EUR {
return amount as EUR
}
function charge(amount: USD) { /* ... */ }
charge(usd(10)) // ok
charge(10) // Error: number is not USD
charge(eur(10)) // Error: EUR is not USDExhaustive Checks
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number }
function area(s: Shape): number {
switch (s.kind) {
case 'circle':
return Math.PI * s.radius ** 2
case 'square':
return s.side ** 2
default:
// if Shape grows, this errors at compile time
const _exhaustive: never = s
return _exhaustive
}
}Declaration Merging
// interfaces with the same name merge
interface Config {
port: number
}
interface Config {
host: string
}
// Config = { port: number; host: string }
// augmenting third-party modules
declare module 'express' {
interface Request {
userId?: string
}
}Module Augmentation
// extend an existing module's types
// e.g., add custom properties to Window
declare global {
interface Window {
analytics: {
track(event: string): void
}
}
}
window.analytics.track('click') // now typed
export {} // makes this file a moduleAdvanced Patterns
Builder Pattern Types
class QueryBuilder<T extends object = {}> {
private filters: Record<string, unknown> = {}
where<K extends string, V>(
key: K, value: V
): QueryBuilder<T & Record<K, V>> {
this.filters[key] = value
return this as any
}
build(): T {
return this.filters as T
}
}
// type accumulates with each call
const q = new QueryBuilder()
.where('name', 'Tammy') // { name: string }
.where('age', 28) // & { age: number }
.build()
// q: { name: string; age: number }Readonly Deep
// recursively make every property readonly
type DeepReadonly<T> = T extends Function
? T
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T
interface Config {
db: { host: string; port: number }
debug: boolean
}
const config: DeepReadonly<Config> = {
db: { host: 'localhost', port: 5432 },
debug: true,
}
config.db.port = 3500 // Error: readonlyLast updated on