Cloud Engineer Lab
Cloud Engineer Lab
Cloud Engineer Lab
Cloud Engineer Lab
© 2026
AutomationIntermediate

TypeScript Patterns I Use Every Day in Production

Six TypeScript patterns that solve real production problems: branded types, discriminated unions, builder patterns, and more — with the reasoning behind each one.

6 min read
Share

TypeScript's type system is significantly more powerful than most teams use in production. Here are the patterns I reach for regularly — not because they're clever, but because they solve specific bugs I've seen in the wild.

1. Branded Types (Prevent ID Confusion)

The classic bug: a function takes both a userId and an orderId, both typed as string. You pass them in the wrong order. TypeScript can't catch it. Users get each other's order history.

Branded types make UserId and OrderId distinct at the type level even though they're both strings at runtime:

typescript
type Brand<B> = { readonly _brand: B }
type Branded<T, B> = T & Brand<B>
 
type UserId = Branded<string, 'UserId'>
type OrderId = Branded<string, 'OrderId'>
 
// Create branded values at your system boundary
function asUserId(id: string): UserId {
  return id as UserId
}
 
// Now TypeScript catches the swap
function getOrder(userId: UserId, orderId: OrderId): Order {
  // ...
}
 
const userId = asUserId(req.user.id)
const orderId = req.params.orderId as OrderId
 
getOrder(orderId, userId) // ❌ Compile error — correct!
getOrder(userId, orderId) // ✅

Zero runtime cost. Entire class of bugs eliminated.

2. Discriminated Unions for State Machines

Don't use optional fields to model state. Use discriminated unions:

typescript
// ❌ The optional-field antipattern
interface Request {
  status: 'pending' | 'success' | 'error'
  data?: ResponseData     // Only when status === 'success'
  error?: string          // Only when status === 'error'
  retryCount?: number     // Only when status === 'error'
}
 
// ✅ Discriminated union — each state carries only what it needs
type Request =
  | { status: 'pending' }
  | { status: 'success'; data: ResponseData }
  | { status: 'error'; error: string; retryCount: number }
 
function handleRequest(req: Request) {
  switch (req.status) {
    case 'pending':
      return <Spinner />
    case 'success':
      return <DataView data={req.data} />  // req.data is non-optional here
    case 'error':
      return <ErrorView error={req.error} retries={req.retryCount} />
  }
}

The compiler exhaustively checks every branch and won't let you access data when the request might be an error.

3. The satisfies Operator for Configuration Objects

Before TypeScript 4.9, you had a choice: annotate the config object and lose inference, or skip annotation and lose type safety. satisfies gives you both:

typescript
const categoryConfig = {
  aws:         { label: 'AWS',         color: '#FF9900' },
  azure:       { label: 'Azure',       color: '#0078D4' },
  devops:      { label: 'DevOps',      color: '#28A745' },
  programming: { label: 'Programming', color: '#6F42C1' },
} satisfies Record<string, { label: string; color: string }>
 
// ✅ TypeScript knows 'aws' is a valid key (inference preserved)
categoryConfig.aws.label
 
// ❌ TypeScript catches invalid keys (validation preserved)
categoryConfig.invalid.label

I use this pattern constantly for route configs, feature flags, and event handlers.

4. Template Literal Types for API Route Safety

Stop using string for API routes. Template literal types let you express route patterns:

typescript
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
type ApiVersion = 'v1' | 'v2'
type ApiRoute = `/${ApiVersion}/${string}`
 
// Route handler registry with full type safety
type RouteHandler = {
  method: HttpMethod
  path: ApiRoute
  handler: (req: Request) => Promise<Response>
}
 
const routes: RouteHandler[] = [
  { method: 'GET',  path: '/v1/users',          handler: listUsers },
  { method: 'POST', path: '/v1/users',          handler: createUser },
  { method: 'GET',  path: '/v2/users/:id',      handler: getUser },
  { method: 'GET',  path: '/v1/invalid-prefix', handler: getUser }, // ❌ Error
]

Combine with Extract and conditional types to build fully type-safe API clients where the response type is inferred from the route.

5. infer for Deep Type Extraction

When you need to extract types from complex generic structures:

typescript
// Extract the resolved type from any Promise
type Awaited<T> = T extends Promise<infer U> ? U : T
 
// Extract array element type
type ArrayElement<T> = T extends (infer U)[] ? U : never
 
// Extract function return type (same as built-in ReturnType<>)
type Return<T> = T extends (...args: any[]) => infer R ? R : never
 
// Practical: extract the type of a Zod schema
import { z } from 'zod'
const UserSchema = z.object({ id: z.string(), name: z.string() })
type User = z.infer<typeof UserSchema>
 
// Now User is { id: string; name: string }
// Schema and type stay in sync automatically

The key insight: infer lets TypeScript pattern-match against type structure and extract sub-types. It's how most utility types in TypeScript's standard library are built.

6. Const Assertions + as const Enums

TypeScript enums have problems (they generate runtime code, are hard to iterate over, and don't play well with external APIs). Use as const objects instead:

typescript
// ❌ Enum — generates runtime JavaScript, can't use values as types
enum Status {
  Pending = 'PENDING',
  Active  = 'ACTIVE',
  Closed  = 'CLOSED',
}
 
// ✅ as const — zero runtime overhead, values are literal types
const Status = {
  Pending: 'PENDING',
  Active:  'ACTIVE',
  Closed:  'CLOSED',
} as const
 
type Status = typeof Status[keyof typeof Status]
// Status = 'PENDING' | 'ACTIVE' | 'CLOSED'
 
// Can iterate at runtime
Object.values(Status).forEach(s => console.log(s))
 
// Can use as discriminant in switch
function handle(status: Status) {
  switch (status) {
    case Status.Pending: return '...'
    case Status.Active:  return '...'
    case Status.Closed:  return '...'
  }
}

The compiler still enforces exhaustiveness checks, you get autocomplete, and there's no generated JavaScript overhead.

Putting It Together

These patterns compose. A real-world example from a payment service:

typescript
// Branded types prevent ID mix-ups
type PaymentId = Branded<string, 'PaymentId'>
type CustomerId = Branded<string, 'CustomerId'>
 
// Discriminated union models the state machine correctly
type Payment =
  | { id: PaymentId; status: 'pending'; customerId: CustomerId; amount: number }
  | { id: PaymentId; status: 'captured'; customerId: CustomerId; amount: number; capturedAt: Date }
  | { id: PaymentId; status: 'failed'; customerId: CustomerId; reason: string; retryEligible: boolean }
  | { id: PaymentId; status: 'refunded'; customerId: CustomerId; amount: number; refundedAt: Date }
 
// satisfies validates config without losing inference
const paymentConfig = {
  pending:  { label: 'Pending',   canCapture: true,  canRefund: false },
  captured: { label: 'Captured',  canCapture: false, canRefund: true  },
  failed:   { label: 'Failed',    canCapture: false, canRefund: false },
  refunded: { label: 'Refunded',  canCapture: false, canRefund: false },
} satisfies Record<Payment['status'], { label: string; canCapture: boolean; canRefund: boolean }>

The compiler now prevents you from modelling illegal state transitions, accessing fields that don't exist for a given status, or mixing up customer and payment IDs. That's an entire class of production bugs eliminated at zero runtime cost.

CChetan Yamger

Written by

Chetan Yamger

Cloud Engineer · AI Automation Architect · Blogger

Cloud Engineer and AI Automation Architect with deep expertise in Azure, Intune, PowerShell, and AI-driven workflows. I use ChatGPT, Gemini, and prompt engineering to build intelligent automation that improves productivity and decision-making in real IT environments.

AI AutomationAzure & IntunePowerShell & PythonNode.js / Next.jsApplication PackagingPower BIGeminiVDI / WVDGitHub ActionsM365Graph APIPrompt Engineering
Newsletter

Stay in the loop.
New articles, straight to you.

Deep-dive technical articles on Intune, PowerShell, and AI — no noise, no spam.

New article notifications
No spam, ever
Free forever

Discussion

Share your thoughts — your email stays private

Leave a comment

0/2000

Your email is used to prevent spam and will never be displayed.