DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Advanced TypeScript Patterns: Branded Types, Template Literals, and Discriminated Unions

TypeScript's type system becomes truly powerful when you stop using it as "annotated JavaScript" and start using it as a design tool. These patterns catch entire classes of bugs at compile time that would otherwise surface in production.

Branded Types for Domain Safety

Plain string and number are interchangeable. Branded types make them distinct:

type UserId = string & { readonly __brand: 'UserId' }
type ProductId = string & { readonly __brand: 'ProductId' }
type EmailAddress = string & { readonly __brand: 'EmailAddress' }

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

function getUser(id: UserId) { /* ... */ }

const productId = createProductId('prod_123')
getUser(productId) // ERROR: Argument of type 'ProductId' is not assignable to 'UserId'
Enter fullscreen mode Exit fullscreen mode

Use branded types for any ID, currency amount, or value that has domain-specific semantics. Passing a ProductId where a UserId is expected becomes a compile-time error.

Template Literal Types

Enforce string format constraints at the type level:

type EventName = `on${Capitalize<string>}`
type CssClass = `${string}-${string}`
type ApiRoute = `/api/${string}`

function addEventListener(event: EventName, handler: () => void) {}

addEventListener('onClick', handler)  // OK
addEventListener('click', handler)    // ERROR: expected 'on...' prefix

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type Endpoint = `${HttpMethod} ${ApiRoute}`

const route: Endpoint = 'GET /api/users'  // OK
const bad: Endpoint = 'FETCH /api/users'  // ERROR
Enter fullscreen mode Exit fullscreen mode

Discriminated Unions for State Machines

Model async state explicitly instead of juggling boolean flags:

type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error }

function UserCard({ state }: { state: AsyncState<User> }) {
  switch (state.status) {
    case 'idle': return <Placeholder />
    case 'loading': return <Spinner />
    case 'success': return <div>{state.data.name}</div>  // data is typed as User
    case 'error': return <div>{state.error.message}</div>  // error is typed as Error
  }
}
Enter fullscreen mode Exit fullscreen mode

TypeScript's exhaustive checking ensures you handle every state. Add a new state variant and every switch statement that doesn't handle it becomes a compile error.

Const Assertions for Derived Types

Derive types from runtime data instead of duplicating them:

const ROLES = ['admin', 'editor', 'viewer'] as const
type Role = typeof ROLES[number]  // 'admin' | 'editor' | 'viewer'

const PERMISSIONS = {
  admin: ['read', 'write', 'delete'],
  editor: ['read', 'write'],
  viewer: ['read'],
} as const

type Permission = typeof PERMISSIONS[Role][number]  // 'read' | 'write' | 'delete'
Enter fullscreen mode Exit fullscreen mode

When you add a new role to ROLES, the Role type updates automatically. No manual type maintenance.

Mapped Types for API Response Transforms

Transform types systematically:

type ApiResponse<T> = {
  [K in keyof T]: T[K] extends Date ? string : T[K]
}

type Nullable<T> = { [K in keyof T]: T[K] | null }
type Optional<T> = { [K in keyof T]?: T[K] }
type Readonly<T> = { readonly [K in keyof T]: T[K] }

// Practical: mark all fields as loading
type LoadingState<T> = { [K in keyof T]: T[K] | undefined }
Enter fullscreen mode Exit fullscreen mode

Conditional Types for Generic Utilities

// Extract the resolved type from a Promise
type Awaited<T> = T extends Promise<infer U> ? U : T

// Extract function return type
type ReturnType<T extends (...args: any) => any> =
  T extends (...args: any) => infer R ? R : never

// Practical: type the result of your API calls
type UserResponse = Awaited<ReturnType<typeof fetchUser>>
Enter fullscreen mode Exit fullscreen mode

The satisfies Operator

Validate against a type while preserving the literal type:

type Color = 'red' | 'green' | 'blue'
type ColorMap = Record<string, Color>

// Without satisfies: palette has type ColorMap, losing literal info
const palette: ColorMap = { primary: 'red', secondary: 'blue' }

// With satisfies: palette is validated as ColorMap but keeps literal types
const palette = {
  primary: 'red',
  secondary: 'blue',
} satisfies ColorMap

palette.primary  // type is 'red', not Color
palette.primary = 'purple'  // ERROR: 'purple' is not a Color
Enter fullscreen mode Exit fullscreen mode

The Ship Fast Skill Pack at whoffagents.com includes a /types skill that scaffolds branded types, discriminated unions, and API response types for your domain. $49 one-time.

Top comments (0)