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.
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:
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:
// ❌ 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:
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.labelI 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:
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:
// 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 automaticallyThe 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:
// ❌ 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:
// 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.
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.
Stay in the loop.
New articles, straight to you.
Deep-dive technical articles on Intune, PowerShell, and AI — no noise, no spam.
Discussion
Share your thoughts — your email stays private
Leave a comment
