TypeScript Patterns I Wish I Knew Earlier
January 8, 2025 · 9 min read
Beyond Generics
Most TypeScript tutorials teach generics, utility types, and basic interfaces. That's the foundation. What I wish I'd learned earlier are the patterns that make large codebases provably correct — where the type system does the work instead of runtime checks.
Discriminated Unions
The most underused pattern in TypeScript. Instead of optional fields that may or may not be present:
// Bad: optional fields make every consumer defensive
type Result = {
data?: User
error?: string
loading?: boolean
}
// Good: discriminated union — compiler knows which case you're in
type Result =
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: string }With the discriminated union, TypeScript narrows the type inside each branch. No undefined checks needed.
function render(result: Result) {
if (result.status === "success") {
return result.data.name // TypeScript knows data exists here
}
}Template Literal Types
Combine string literals to express constrained string formats at the type level:
type EventName = `on${Capitalize<string>}`
// Valid: "onClick", "onChange", "onSubmit"
// Invalid: "click", "handleClick"
type RouteKey = `/${string}`
// Ensures all route strings start with /I use this for API endpoint types, event names, and CSS custom property names. Catches typos at compile time instead of runtime.
Branded Types
Prevent mixing semantically different values that have the same underlying type:
type UserId = string & { readonly __brand: "UserId" }
type PostId = string & { readonly __brand: "PostId" }
function createUserId(id: string): UserId {
return id as UserId
}
function getPost(userId: UserId, postId: PostId) { }
// Fails at compile time — no more mixed-up ID arguments:
getPost(postId, userId) // Error: PostId not assignable to UserIdBefore branded types, mixing up IDs in the wrong argument position was a runtime bug. Now it's a compile error.
The satisfies Operator
TypeScript 4.9 added satisfies — one of my favorite additions. It validates that a value matches a type without widening it:
type Config = Record<string, string | number>
// With "as": type is Config — you lose the specific keys
const config = {
timeout: 5000,
host: "localhost",
} as Config
// With "satisfies": type is { timeout: number, host: string }
const config = {
timeout: 5000,
host: "localhost",
} satisfies Config
config.timeout.toFixed() // Works — TypeScript knows it's a number, not string | numberConst Assertions
Prevent TypeScript from widening literal types:
// Without: type is string[]
const routes = ["/home", "/about", "/contact"]
// With: type is readonly ["/home", "/about", "/contact"]
const routes = ["/home", "/about", "/contact"] as const
type Route = typeof routes[number] // "/home" | "/about" | "/contact"Now you have a type automatically kept in sync with your array. No manual union type to maintain.
The Underlying Principle
Each of these patterns moves validation from runtime to compile time. Runtime errors are caught in production. Compile errors are caught in your editor.
The goal is to make illegal states unrepresentable — to design your types so that code producing invalid state simply doesn't compile. That's where TypeScript earns its reputation.
← Back to Blog