Advanced TypeScript Patterns Every Senior Engineer Should Know
From discriminated unions and branded types to variadic tuples and module augmentation: the TypeScript patterns that catch real bugs.
Most TypeScript codebases are just JavaScript with type annotations. That's not an insult and that's a reasonable place to start. The basic stuff (interfaces, generics, union types) genuinely helps. But you're leaving a lot on the table. The patterns that actually prevent production bugs tend to live further out in the type system: the ones that make illegal states unrepresentable, that turn runtime errors into compile-time errors, that let the compiler catch the mistakes your tests don't cover.
I'll say upfront: you don't need all of this. Use patterns that solve actual problems you have. But knowing these patterns means you'll recognize when you have a problem they'd solve.
Discriminated Unions and Exhaustive Checks
A discriminated union is a union type where each member has a literal property (the discriminant) that the type checker uses to narrow. This is the foundation of building state machines that can't get into bad states. The classic use case is async data fetching, where loading/error/success are fundamentally different shapes that shouldn't be conflated.
type ApiResponse<T> =
| { status: 'loading' }
| { status: 'error'; error: string; retryable: boolean }
| { status: 'success'; data: T }
// Exhaustive check using a never-returning helper
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`)
}
function renderResponse<T>(resp: ApiResponse<T>): string {
switch (resp.status) {
case 'loading': return 'Loading...'
case 'error': return `Error: ${resp.error}`
case 'success': return `Data: ${JSON.stringify(resp.data)}`
default: return assertNever(resp) // Compiler error if new case added
}
}The assertNever pattern turns "we forgot to handle the new variant" from a silent runtime bug into a compile-time error. Add a new member to ApiResponse and the switch statement breaks at compile time, not in production. I've caught real refactor bugs this way that tests would have missed.
Branded Types for Domain Safety
TypeScript has structural typing: two types with the same shape are interchangeable. This is a problem when you have multiple strings that are semantically different. User IDs, order IDs, email addresses. The type system won't stop you from passing one where the other is expected, and this kind of argument confusion is a surprisingly common source of bugs in production systems.
// Naive : both are just 'string' at the type level
type UserId = string
type OrderId = string
function getOrder(userId: UserId, orderId: OrderId) { /* ... */ }
getOrder(orderId, userId) // Compiles! Arguments are silently swapped.
// Branded types : nominal typing in a structural type system
declare const __brand: unique symbol
type Brand<T, B> = T & { readonly [__brand]: B }
type UserId = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>
// Only way to create branded values : force through a constructor
const toUserId = (id: string): UserId => id as UserId
const toOrderId = (id: string): OrderId => id as OrderId
function getOrder(userId: UserId, orderId: OrderId) { /* ... */ }
getOrder(toOrderId('ord_123'), toUserId('usr_456')) // Compile error : correct!Branded types are particularly valuable for IDs, monetary values (USD vs. EUR), validated strings (sanitized HTML, parsed URLs), and measurement units. Zero runtime overhead. They eliminate an entire class of argument-confusion bugs that code review routinely misses.
Conditional Types and the infer Keyword
Conditional types let you express type-level logic: "if T extends U, the type is A; otherwise it's B." The infer keyword lets you capture parts of a matched type. This is how the standard library utilities like ReturnType and Awaited are built.
// Extract the resolved type from a Promise
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T
// Extract function return type (built-in as ReturnType<T>, but instructive)
type MyReturnType<T extends (...args: any[]) => any> =
T extends (...args: any[]) => infer R ? R : never
// Extract the element type from an array
type ElementOf<T> = T extends (infer E)[] ? E : never
type E = ElementOf<string[]> // string
// Deeply unwrap nested Promises
type DeepAwaited<T> =
T extends Promise<infer Inner>
? DeepAwaited<Inner>
: T
// Extract parameter names as string literals
type ParamNames<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ParamNames<`/${Rest}`>
: T extends `${string}:${infer Param}`
? Param
: never
type Params = ParamNames<'/users/:userId/orders/:orderId'>
// => "userId" | "orderId"Template Literal Types
Template literal types let you construct string union types at the type level. This is the backbone of typed event systems, CSS-in-JS, and route builders. It's one of the features that surprised me most when it shipped. The amount of string typo bugs it can eliminate is real.
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
type ApiRoute = '/users' | '/orders' | '/products'
// Enumerate all valid route+method combinations as string literals
type RouteKey = `${HttpMethod} ${ApiRoute}`
// => "GET /users" | "POST /users" | "GET /orders" | ...
// Typed event emitter
type EventMap = {
userCreated: { userId: string }
orderPlaced: { orderId: string; amount: number }
}
type EventHandler<K extends keyof EventMap> = (payload: EventMap[K]) => void
type EventNames = keyof EventMap // 'userCreated' | 'orderPlaced'
type OnEvent = `on${Capitalize<EventNames>}`
// => 'onUserCreated' | 'onOrderPlaced'
// CSS property builder : only valid combinations compile
type CSSUnit = 'px' | 'rem' | 'em' | '%'
type SpacingValue = `${number}${CSSUnit}`
const margin: SpacingValue = '16px' // OK
const padding: SpacingValue = '2invalid' // ErrorMapped Types and Key Remapping
Mapped types let you transform every key of an existing type. Combined with key remapping, they're how you build generic transformation utilities without code generation. The built-in utilities (Partial, Required, Readonly) are all implemented as mapped types.
// Make all properties optional and nullable
type Nullable<T> = { [K in keyof T]: T[K] | null }
type PartialNullable<T> = { [K in keyof T]?: T[K] | null }
// Deep readonly
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]
}
// Key remapping : prefix all keys with 'get'
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>
// => { getName: () => string; getEmail: () => string; getAge: () => number }
// Filter keys by value type
type PickByValue<T, ValueType> = {
[K in keyof T as T[K] extends ValueType ? K : never]: T[K]
}
type StringFields = PickByValue<User, string>
// => { name: string; email: string }The satisfies Operator
The satisfies operator changed how I write config objects. It was introduced in TypeScript 4.9 and it solves a real tension: you want to validate that a value conforms to a type, but you also want to keep the more specific inferred type rather than widening it to the annotation.
const palette = {
red: [255, 0, 0],
green: '#00ff00',
blue: [0, 0, 255],
} satisfies Record<string, string | number[]>
// With 'as const' assertion or annotation, palette.red would be 'string | number[]'
// With satisfies, the specific type is preserved:
palette.red.at(0) // OK : TypeScript knows it's number[], not string | number[]
palette.green.toUpperCase() // OK : TypeScript knows it's string
// Validate config objects without losing key autocomplete
type Config = { host: string; port: number; ssl: boolean }
const config = {
host: 'localhost',
port: 5432,
ssl: false,
// extra: 'value', // Error : not in Config
} satisfies ConfigNoInfer and Controlling Type Inference
TypeScript 5.4 introduced NoInfer<T>, which tells the compiler not to use a particular position for inference. It's a niche tool but it fixes a genuinely annoying problem: generic functions where one parameter should constrain another without itself contributing to inference.
// Before NoInfer : TypeScript widens T based on the default value
function createState<T>(initial: T, defaultValue: T): T { return initial }
createState(42, 'hello') // T inferred as string | number : probably not what you want
// With NoInfer : default only validates against T, doesn't contribute to inference
function createState<T>(initial: T, defaultValue: NoInfer<T>): T { return initial }
createState(42, 'hello') // Error : Argument of type 'string' not assignable to 'number'
createState(42, 0) // OK : T inferred as number from first argumentVariadic Tuple Types
Variadic tuples let you express spread operations at the type level, enabling properly typed function composition and argument forwarding. This is TypeScript 4.0+ and it's what makes typed curry and pipe functions actually work.
// Typed function composition
type Unshift<T extends unknown[], U> = [U, ...T]
type Shift<T extends unknown[]> = T extends [unknown, ...infer U] ? U : never
// Prepend an argument to a function type
type PrependArg<F extends (...args: any[]) => any, Arg> =
(...args: Unshift<Parameters<F>, Arg>) => ReturnType<F>
// Typed curry
function curry<T extends unknown[], R>(
fn: (...args: T) => R
): T extends [infer First, ...infer Rest]
? (arg: First) => (...args: Rest) => R
: () => R {
return ((first: any) => (...rest: any[]) => fn(first, ...rest)) as any
}
const add = (a: number, b: number): number => a + b
const addOne = curry(add)(1)
addOne(5) // 6 : fully typed
addOne('5') // Compile errorThe Builder Pattern with Method Chaining
TypeScript's type system can track builder state, preventing you from calling methods in the wrong order or building incomplete objects. This is useful for query builders, test fixture builders, and any fluent interface where completeness matters.
// Builder that enforces required fields at compile time
class QueryBuilder<T extends object, Required extends keyof T = never> {
private filters: Partial<T> = {}
private _limit?: number
where<K extends keyof T>(key: K, value: T[K]): QueryBuilder<T, Required | K> {
this.filters[key] = value
return this as any
}
limit(n: number): this {
this._limit = n
return this
}
// Only callable when all required fields have been provided
build(this: QueryBuilder<T, keyof T>): { filters: Partial<T>; limit?: number } {
return { filters: this.filters, limit: this._limit }
}
}
interface UserQuery { userId: string; status: string }
const builder = new QueryBuilder<UserQuery>()
builder.build() // Error : userId and status not set
builder.where('userId', 'u_123').build() // Error : status not set
builder.where('userId', 'u_123').where('status', 'active').build() // OKModule Augmentation
Module augmentation lets you extend types from third-party libraries or global namespaces without forking them. This is how you add type-safe context to Express requests, extend environment variable types, or add custom methods to built-in prototypes safely.
// Extending Express Request with authenticated user
// In types/express/index.d.ts
import { User } from '../models/user'
declare global {
namespace Express {
interface Request {
user?: User
requestId: string
}
}
}
// Extending process.env to typed env vars
// In types/env.d.ts
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: 'development' | 'production' | 'test'
DATABASE_URL: string
REDIS_URL: string
PORT?: string
}
}
// Now process.env.DATABASE_URL is string (not string | undefined)
// and NODE_ENV has a typed union instead of stringThe goal of advanced TypeScript is not impressive type gymnastics. It's shrinking the gap between what you intend your code to do and what the compiler will permit. Every type-level invariant you express is one fewer class of bug your tests need to catch.
TypeScript is worth the overhead. Not because it prevents all bugs, but because it makes the ones you do have easier to find. Branded types, discriminated unions, and satisfies have caught real production bugs in my experience, bugs that tests missed and code review missed. You don't need to use all of this. But the next time you see an argument confusion bug or a missing switch case, you'll know exactly which pattern would have caught it at compile time.