Types at the Boundary
Inside a well-tested module, types mostly get out of your way — the compiler infers most things correctly. At the boundary between modules, between your code and an API, or between your code and user input, types are where you earn back time you'd otherwise spend debugging.
These are the TypeScript patterns I reach for at those boundaries.
Branded Types for Primitives That Aren't Interchangeable
A userId and an accountId are both strings, but they're not interchangeable. Without branding, the compiler will happily let you pass one where the other is expected.
type UserId = string & { readonly __brand: 'UserId' }
type AccountId = string & { readonly __brand: 'AccountId' }
function createUserId(id: string): UserId {
return id as UserId
}
function getUser(id: UserId): Promise<User> { ... }
function getAccount(id: AccountId): Promise<Account> { ... }
const userId = createUserId('usr_123')
const accountId = '...' as AccountId
getUser(accountId) // TS error — AccountId is not assignable to UserId
getAccount(userId) // TS error
The runtime cost is zero — brands are erased at compile time. The benefit is that the compiler catches an entire class of argument-ordering bugs.
Discriminated Unions for State
Instead of a bag of nullable fields, use a discriminated union to model state explicitly:
// Instead of this — every consumer must check all fields
type RequestState = {
loading: boolean
data: User | null
error: string | null
}
// Use this — each state is mutually exclusive
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; error: string }
TypeScript narrows the type in each branch of a switch or if statement:
function UserProfile({ state }: { state: RequestState }) {
switch (state.status) {
case 'idle': return <Placeholder />
case 'loading': return <Spinner />
case 'success': return <Profile user={state.data} /> // state.data is User, not User | null
case 'error': return <ErrorMessage message={state.error} /> // state.error is string
}
}
The state.data on the success branch is typed as User, not User | null. No null check needed.
satisfies for Configuration Objects
The satisfies operator checks that a value matches a type without widening the type to it. This is useful for typed configuration objects where you want autocompletion and type checking, but need to preserve the exact literal types.
const ROUTES = {
home: '/',
blogs: '/blogs',
about: '/about',
} satisfies Record<string, string>
// Without satisfies, this would be string — not '/blogs'
type BlogRoute = typeof ROUTES.blogs // '/blogs' (literal type preserved)
// Still type-checked as Record<string, string>
const invalid = {
home: 42, // Error — number is not string
} satisfies Record<string, string>
Zod for Runtime Validation
TypeScript types are erased at runtime. When data comes from outside your system — an HTTP request body, an API response, a form submission — you need runtime validation that produces proper TypeScript types.
import { z } from 'zod'
const CreateTransactionSchema = z.object({
amount: z.number().int().positive(),
currency: z.enum(['MYR', 'SGD', 'USD']),
description: z.string().min(1).max(255),
idempotencyKey: z.string().uuid(),
recipientAccountId: z.string(),
})
type CreateTransactionRequest = z.infer<typeof CreateTransactionSchema>
// In your route handler
export async function POST(req: Request) {
const body = await req.json()
const result = CreateTransactionSchema.safeParse(body)
if (!result.success) {
return Response.json(
{ error: result.error.flatten() },
{ status: 400 }
)
}
// result.data is fully typed as CreateTransactionRequest
const transaction = await createTransaction(result.data)
return Response.json(transaction)
}
The schema is the single source of truth for both validation and types. Change the schema, the types update automatically.
Template Literal Types for String Patterns
TypeScript can enforce string patterns at the type level:
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
type ApiPath = `/${string}`
type Endpoint = `${HttpMethod} ${ApiPath}`
function registerRoute(endpoint: Endpoint, handler: () => void) { ... }
registerRoute('GET /users', handler) // OK
registerRoute('POST /users/create', handler) // OK
registerRoute('INVALID /users', handler) // Error — not a valid HttpMethod
registerRoute('GET users', handler) // Error — path must start with /
This is useful for event systems, route registration, and any domain that has structured string formats.
The Principle Behind the Patterns
All of these techniques share a goal: make invalid states unrepresentable. If the wrong state can't be expressed in the type system, it can't reach production. Every bug you prevent at compile time is one you don't debug in production at midnight.
The most powerful type system in the world doesn't help if you use any freely or skip validation at boundaries. Types earn their value at the edges — lean into that.