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.
Key takeaways
- Discriminated unions plus an assertNever helper turn a forgotten switch case from a silent runtime bug into a compile-time error, because adding a new union member breaks the build.
- Branded types add nominal typing to TypeScript's structural type system, which kills argument-confusion bugs like swapping a UserId and an OrderId, and they carry zero runtime overhead.
- The satisfies operator, added in TypeScript 4.9, validates a value against a type while preserving the narrower inferred type instead of widening it to the annotation.
- NoInfer, added in TypeScript 5.4, stops a parameter from contributing to generic inference, so createState(42, "hello") errors instead of silently widening T to string | number.
- The point of advanced TypeScript is shrinking the gap between what you intend and what the compiler permits, not type gymnastics. Every type-level invariant is one fewer class of bug your tests have to catch.
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.
Frequently asked questions
- What is a branded type in TypeScript and why would I use one?
- A branded type attaches a phantom property to a primitive so two types with the same shape stop being interchangeable. TypeScript is structurally typed, so a UserId and an OrderId that are both string can be swapped silently in a function call. Branding forces values through a constructor and makes that swap a compile error. It costs nothing at runtime and it's especially valuable for IDs, currency amounts, and validated strings.
- How do I make a TypeScript switch statement exhaustive?
- Add a default case that calls a helper typed to accept never, usually called assertNever. If every union member is handled, the value reaching default is narrowed to never and it compiles. Add a new member to the union and the default case stops type-checking, so the compiler tells you exactly where you forgot to handle it. This catches refactor bugs that tests routinely miss.
- What does the satisfies operator do?
- It checks that a value conforms to a type without widening the value to that type. If you annotate a palette object as Record<string, string | number[]>, every property loses its specific type. With satisfies, the check happens but palette.red stays number[] and palette.green stays string, so you keep autocomplete and method access. It shipped in TypeScript 4.9 and it's the right default for config objects.
- Do I need to learn all of these advanced TypeScript patterns?
- No. Use the patterns that solve problems you actually have. The value of knowing them is recognition: the next time you hit an argument-confusion bug or a missing switch case in production, you'll know which pattern would have caught it at compile time. Branded types, discriminated unions, and satisfies are the three that pay for themselves fastest.
Written by
Tech Talk News Editorial
Computer engineering background. Writes about software, AI, markets, and real estate, and the places where the three meet.
More about the author