TypeScriptAdvanced Type Patterns

Advanced Type Patterns

TypeScript's type system is Turing-complete — you can express sophisticated compile-time logic, enforce invariants, and build APIs that guide users toward correct usage. This page covers patterns used in production libraries and large codebases.

Builder Pattern with Method Chaining

The builder pattern lets you construct complex objects step by step. TypeScript makes it even more powerful by tracking which properties have been set at the type level, so .build() can be restricted until required fields are present.

TS
type Prettify<T> = { [K in keyof T]: T[K] } & {}

class QueryBuilder<T extends object = {}> {
  private params: Record<string, unknown> = {}

  select<K extends string>(field: K): QueryBuilder<T & Record<K, string>> {
    this.params[field] = field
    return this as unknown as QueryBuilder<T & Record<K, string>>
  }

  where(condition: string): this {
    this.params['_where'] = condition
    return this
  }

  limit(n: number): this {
    this.params['_limit'] = n
    return this
  }

  build(): Prettify<T> {
    return this.params as Prettify<T>
  }
}

const query = new QueryBuilder()
  .select('id')
  .select('name')
  .select('email')
  .where('active = true')
  .limit(10)
  .build()

// query is inferred as: { id: string; name: string; email: string }
console.log(query)
Fluent API with Polymorphic this

A fluent API returns this so methods chain naturally. TypeScript preserves the concrete subtype through method chains using polymorphic this — without it, a subclass method returning the base class would lose subclass methods.

TS
class BaseConfig {
  protected data: Record<string, unknown> = {}

  setTimeout(ms: number): this {
    this.data.timeout = ms
    return this
  }

  setRetries(n: number): this {
    this.data.retries = n
    return this
  }
}

class HttpConfig extends BaseConfig {
  setBaseUrl(url: string): this {
    this.data.baseUrl = url
    return this
  }

  setHeaders(headers: Record<string, string>): this {
    this.data.headers = headers
    return this
  }
}

// Without polymorphic `this`, setTimeout() would return BaseConfig
// and .setBaseUrl() would not exist on the result.
const config = new HttpConfig()
  .setBaseUrl('https://api.example.com')
  .setHeaders({ Authorization: 'Bearer token' })
  .setTimeout(5000)
  .setRetries(3)

// config is still HttpConfig — all methods remain accessible
The satisfies Operator (TypeScript 4.9)

satisfies validates that a value matches a type while keeping the most specific inferred type. Before satisfies, you had to choose: annotate the variable (lose literal types) or skip annotation (lose validation).

TS
type Color = 'red' | 'green' | 'blue'
type ColorMap = Record<string, Color | [number, number, number]>

// Without satisfies — annotation widens the type
const palette1: ColorMap = {
  primary: 'red',
  secondary: [0, 128, 255],
}
// palette1.primary is Color | [number, number, number]
// Can't safely call string methods on it!

// With satisfies — validates AND keeps the narrow inferred type
const palette2 = {
  primary: 'red',
  secondary: [0, 128, 255],
} satisfies ColorMap

// palette2.primary is 'red'  (literal preserved)
// palette2.secondary is [number, number, number]  (tuple preserved)
const upper = palette2.primary.toUpperCase()  // OK
const [r, g, b] = palette2.secondary          // OK

// Real-world: config objects
type RouteConfig = { path: string; component: string }
const routes = {
  home:  { path: '/',      component: 'HomePage' },
  about: { path: '/about', component: 'AboutPage' },
} satisfies Record<string, RouteConfig>

// routes.home.path is '/' (literal), not string
Tip
Use satisfies when you want validation without losing the literal types that TypeScript infers. It is especially useful for config objects, route maps, and const data tables.
infer Tricks

The infer keyword extracts types from within other types inside conditional type branches. Combined with conditional types it enables powerful type-level transformations.

TS
// Unpack a Promise to its resolved type
type UnpackPromise<T> = T extends Promise<infer U> ? U : T

type A = UnpackPromise<Promise<string>>   // string
type B = UnpackPromise<Promise<number[]>> // number[]
type C = UnpackPromise<boolean>           // boolean (passthrough)

// Deep unpack — handles nested promises
type DeepUnpack<T> = T extends Promise<infer U> ? DeepUnpack<U> : T

type D = DeepUnpack<Promise<Promise<string>>> // string

// Head and Tail of a tuple
type Head<T extends unknown[]> = T extends [infer H, ...unknown[]] ? H : never
type Tail<T extends unknown[]> = T extends [unknown, ...infer R]   ? R : never

type H  = Head<[string, number, boolean]> // string
type Tl = Tail<[string, number, boolean]> // [number, boolean]

// Extract array element type
type ElementType<T> = T extends (infer E)[] ? E : never
type E = ElementType<string[]> // string

// Unpack constructor instance type
type InstanceOf<T> = T extends new (...args: unknown[]) => infer I ? I : never
class MyClass { value = 42 }
type MyInstance = InstanceOf<typeof MyClass> // MyClass

// Extract the first argument of a function
type FirstArg<T extends (...args: never[]) => unknown> =
  T extends (first: infer F, ...rest: never[]) => unknown ? F : never

type F = FirstArg<(x: string, y: number) => void> // string
Type-Safe Event Emitter

A generic EventMap constrains event names and their payload types so typos and wrong argument shapes are caught at compile time, not at runtime.

TS
type EventMap = Record<string, unknown[]>

class TypedEventEmitter<Events extends EventMap> {
  private listeners = new Map<keyof Events, Set<(...args: unknown[]) => void>>()

  on<K extends keyof Events>(
    event: K,
    listener: (...args: Events[K]) => void
  ): this {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set())
    }
    this.listeners.get(event)!.add(listener as (...args: unknown[]) => void)
    return this
  }

  off<K extends keyof Events>(
    event: K,
    listener: (...args: Events[K]) => void
  ): this {
    this.listeners.get(event)?.delete(listener as (...args: unknown[]) => void)
    return this
  }

  emit<K extends keyof Events>(event: K, ...args: Events[K]): boolean {
    const set = this.listeners.get(event)
    if (!set?.size) return false
    set.forEach(fn => fn(...args))
    return true
  }
}

// Define your event contract
interface AppEvents extends EventMap {
  login:   [userId: string, timestamp: number]
  logout:  [userId: string]
  message: [from: string, to: string, body: string]
  error:   [code: number, message: string]
}

const emitter = new TypedEventEmitter<AppEvents>()

emitter.on('login', (userId, timestamp) => {
  // userId: string, timestamp: number  — fully typed
  console.log(`User ${userId} logged in at ${timestamp}`)
})

emitter.emit('login', 'user-42', Date.now()) // OK

// emitter.on('wrongEvent', () => {})       // Error: not in AppEvents
// emitter.emit('login', 123, 'bad')        // Error: wrong arg types
NoInfer Utility (TypeScript 5.4)

NoInfer&lt;T&gt; tells TypeScript not to use a specific occurrence of a type parameter as a site for inference. This prevents unwanted widening when you want one argument to constrain another.

TS
// Without NoInfer — TypeScript infers T from both args,
// widening to their union. The validator is barely constrained.
function createStore_bad<T>(
  initial: T,
  validator: (value: T) => boolean
): T {
  if (!validator(initial)) throw new Error('Invalid')
  return initial
}

// With NoInfer — T is inferred only from initial,
// then validator is checked against that fixed T.
function createStore<T>(
  initial: T,
  validator: (value: NoInfer<T>) => boolean
): T {
  if (!validator(initial)) throw new Error('Invalid')
  return initial
}

// T = string, validator receives string
const store = createStore('hello', v => v.length > 0)

// Practical: default values
function withDefault<T>(value: T | undefined, fallback: NoInfer<T>): T {
  return value ?? fallback
}

const result = withDefault<'a' | 'b'>(undefined, 'a') // OK
// withDefault<'a' | 'b'>(undefined, 'c')              // Error: 'c' not assignable
Const Type Parameters (TypeScript 5.0)

Adding const before a type parameter causes TypeScript to infer literal and tuple types instead of widening to string, number, or unknown[].

TS
// Without const — T widens to string[]
function identity_wide<T>(value: T): T { return value }
const a = identity_wide(['x', 'y']) // string[]

// With const — T stays as readonly ['x', 'y']
function identity_narrow<const T>(value: T): T { return value }
const b = identity_narrow(['x', 'y']) // readonly ['x', 'y']

// Practical: route definitions
function defineRoutes<const T extends Record<string, string>>(routes: T): T {
  return routes
}

const routes = defineRoutes({
  home:  '/',
  about: '/about',
  blog:  '/blog/:slug',
})

// routes.home is '/' (literal), not string
type AppRoute = (typeof routes)[keyof typeof routes]
// '/' | '/about' | '/blog/:slug'

// Practical: column definitions for a table library
function createTable<const Cols extends readonly string[]>(columns: Cols) {
  type ColName = Cols[number]
  return {
    sortBy(col: ColName) { return col }
  }
}

const table = createTable(['id', 'name', 'email'] as const)
table.sortBy('name')  // OK
// table.sortBy('phone') // Error: not in column list
Template Literal Type Tricks

Template literal types compose string types at the type level, enabling typed CSS properties, event naming conventions, API route generation, and deep key paths.

TS
// CSS property builder
type Side = 'top' | 'right' | 'bottom' | 'left'
type CSSProp = `margin-${Side}` | `padding-${Side}`
// 'margin-top' | 'margin-right' | ... | 'padding-top' | ...

// Event handler naming convention
type EventName<T extends string> = `on${Capitalize<T>}`
type Actions  = 'click' | 'focus' | 'blur' | 'change'
type Handlers = EventName<Actions>
// 'onClick' | 'onFocus' | 'onBlur' | 'onChange'

// Getter/setter pair generation
type Getter<T extends string> = `get${Capitalize<T>}`
type Setter<T extends string> = `set${Capitalize<T>}`
type Fields = 'name' | 'age' | 'email'
type GetterAPI = { [K in Fields as Getter<K>]: () => string }
type SetterAPI = { [K in Fields as Setter<K>]: (v: string) => void }

// Deep dot-notation key paths
type Paths<T, Prefix extends string = ''> = {
  [K in keyof T & string]: T[K] extends object
    ? Paths<T[K], `${Prefix}${K}.`>
    : `${Prefix}${K}`
}[keyof T & string]

interface Config {
  db:  { host: string; port: number }
  app: { name: string; debug: boolean }
}

type ConfigPath = Paths<Config>
// 'db.host' | 'db.port' | 'app.name' | 'app.debug'

function getConfig(path: ConfigPath): unknown {
  return path
}

getConfig('db.host')  // OK
// getConfig('db.x')  // Error
Exhaustive Switch with never

Using never for exhaustiveness checking ensures your switch or if-else chain handles every possible discriminated union variant. Add a new variant later and TypeScript immediately flags every unhandled switch.

TS
type Shape =
  | { kind: 'circle';    radius: number }
  | { kind: 'rectangle'; width: number; height: number }
  | { kind: 'triangle';  base: number;  height: number }

function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`)
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2
    case 'rectangle':
      return shape.width * shape.height
    case 'triangle':
      return 0.5 * shape.base * shape.height
    default:
      // If a new variant is added to Shape without updating this
      // switch, TypeScript errors here — shape won't narrow to never.
      return assertNever(shape)
  }
}

console.log(area({ kind: 'circle', radius: 5 })) // 78.53...
Discriminated Union Helpers

TS
type Result<T, E = Error> =
  | { success: true;  value: T }
  | { success: false; error: E }

// Extract types from a Result
type ResultValue<R> = R extends { success: true;  value: infer V } ? V : never
type ResultError<R> = R extends { success: false; error: infer E  } ? E : never

type ApiResult = Result<{ id: string; name: string }, { code: number }>
type Value = ResultValue<ApiResult> // { id: string; name: string }
type Err   = ResultError<ApiResult> // { code: number }

// Narrow a union by discriminant value
type DiscriminateUnion<T, K extends keyof T, V extends T[K]> =
  T extends Record<K, V> ? T : never

type SuccessResult = DiscriminateUnion<ApiResult, 'success', true>
// { success: true; value: { id: string; name: string } }

// Functor-style map over Result
function mapResult<T, U, E>(
  result: Result<T, E>,
  fn: (value: T) => U
): Result<U, E> {
  return result.success
    ? { success: true,  value: fn(result.value) }
    : result
}

const res: Result<number> = { success: true, value: 42 }
const doubled = mapResult(res, n => n * 2)
// doubled.value === 84
Opaque / Branded Types

TypeScript uses structural typing — two types with the same shape are interchangeable. Branded types add a phantom marker to prevent accidentally mixing structurally identical types like different ID strings.

TS
declare const __brand: unique symbol
type Brand<T, B> = T & { readonly [__brand]: B }

type UserId  = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>

function createUserId(id: string):  UserId  { return id as UserId  }
function createOrderId(id: string): OrderId { return id as OrderId }

function getUserById(id: UserId): void {
  console.log(`Fetching user ${id}`)
}

const uid = createUserId('user-1')
const oid = createOrderId('order-1')

getUserById(uid)  // OK
// getUserById(oid)          // Error: OrderId not assignable to UserId
// getUserById('raw-string') // Error: string not assignable to UserId

// Also useful for validated strings
type Email        = Brand<string, 'Email'>
type PositiveInt  = Brand<number, 'PositiveInt'>

function parseEmail(raw: string): Email {
  if (!raw.includes('@')) throw new Error('Invalid email')
  return raw as Email
}

function parsePositiveInt(n: number): PositiveInt {
  if (n <= 0 || !Number.isInteger(n)) throw new Error('Must be positive integer')
  return n as PositiveInt
}
Variance Annotations

TypeScript 4.7 introduced explicit variance annotations. They document intent, speed up type checking in large codebases, and cause compile errors if your implementation contradicts the annotation.

TS
// Covariant — output position, subtypes can flow up
interface Producer<out T> {
  produce(): T
}

// Contravariant — input position, supertypes can flow down
interface Consumer<in T> {
  consume(value: T): void
}

// Invariant — both read and write (default)
interface Container<T> {
  value: T
  write(v: T): void
}

// TypeScript verifies: if Producer<string> is used where
// Producer<string | number> is expected, it is valid because
// a string is a valid string | number.

const strProducer: Producer<string> = { produce: () => 'hello' }
const anyProducer: Producer<string | number> = strProducer // OK (covariant)

const anyConsumer: Consumer<string | number> = { consume: (v) => console.log(v) }
const strConsumer: Consumer<string> = anyConsumer // OK (contravariant)
Success
You now have a toolkit of advanced patterns: builders, fluent APIs, satisfies, infer, typed event emitters, NoInfer, const type parameters, template literal types, exhaustive switches, discriminated union helpers, branded types, and variance annotations. These form the foundation of well-typed library and application code.