There's a moment every dev working with TypeScript eventually hits — the compiler flags an error and you think: "how did I not see that?"
That moment is addictive. And the patterns I'm laying out here are designed to give TypeScript that moment on your behalf, before the bug ever gets anywhere near production.
These aren't GoF design patterns. They're type system patterns — tools that make entire categories of bugs impossible to write. Whether you've been writing TypeScript for one year or ten, at least one of these is going to surprise you.
The repo with all the working code is on GitHub — every file compiles with the strictest tsconfig I could put together.
01. Discriminated Unions — eliminate impossible states
The first bug this pattern kills is one we've all written: the object with three boolean flags.
// ❌ This allows 8 combinations. Most of them make no sense.
interface FetchState {
isLoading: boolean
data: User | null
error: Error | null
}
// What do you do with this?
const state = { isLoading: true, data: someUser, error: someError }
Three booleans = 2³ = 8 possible combinations. Of those 8, maybe 3 are valid in your app. The other 5 are impossible states your code should never see — but TypeScript can't catch them because structurally they're all valid.
The fix is a discriminant field that lets TypeScript know exactly which state you're in:
// ✅ Only 4 combinations, all valid
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error }
function renderFetch(state: FetchState<User>): string {
switch (state.status) {
case "success":
return `Hello, ${state.data.name}` // TypeScript knows data exists here
case "error":
return `Error: ${state.error.message}` // and that error exists here
// ...
}
}
The status field is the discriminant. When you enter case "success", TypeScript narrows the type automatically and knows data exists and isn't null. Not a single !. in sight.
Where I use this in production: fetch states, form lifecycles, upload states, and especially the post lifecycle on this blog: draft → scheduled → published → archived.
02. Branded Types — never put an ID in the wrong place again
This pattern solves a problem that seems trivial until it bites you in production.
// ❌ Both are string — TypeScript can't tell them apart
function getPost(userId: string, postId: string) { ... }
const userId = "user_123"
const postId = "post_456"
getPost(postId, userId) // compiles, deploys, breaks
TypeScript is structural: if two types have the same shape, they're interchangeable. UserId and PostId are both string, so TypeScript happily accepts them in any order.
The fix is adding a "brand" to the type that only exists in the type system — not at runtime:
type Brand<T, B extends string> = T & { readonly __brand: B }
type UserId = Brand<string, "UserId">
type PostId = Brand<string, "PostId">
function getPost(userId: UserId, postId: PostId) { ... }
const uid = "user_123" as UserId
const pid = "post_456" as PostId
getPost(uid, pid) // ✅
getPost(pid, uid) // ❌ Compile-time error — exactly what we want
The __brand property never exists at runtime (it's a phantom intersection), but it makes TypeScript treat them as nominally distinct types. Zero overhead.
I take it a step further with smart constructors that validate at the system boundary:
function createUserId(raw: string): UserId {
if (!raw.startsWith("user_")) throw new Error(`Invalid ID: ${raw}`)
return raw as UserId
}
Once a value passes through the constructor, you trust the type everywhere inside the system. Same principle as parse, don't validate.
03. satisfies + as const — validate without losing your literals
There's an uncomfortable trade-off when you annotate objects in TypeScript: add a type annotation and you lose the literals. Skip the annotation and you lose the validation.
type Role = "admin" | "editor" | "reader"
// ❌ Annotating with Record widens the values — loses true/false as literals
const perms: Record<Role, { canPublish: boolean }> = {
admin: { canPublish: true },
}
// perms.admin.canPublish is boolean, not true
// ❌ Without annotation, TypeScript won't warn if you forget a role
const perms2 = {
admin: { canPublish: true },
// ...forgot editor and reader
}
satisfies solves exactly this trade-off: it validates the shape without widening the types:
const perms = {
admin: { canPublish: true, canEdit: true },
editor: { canPublish: false, canEdit: true },
reader: { canPublish: false, canEdit: false },
} satisfies Record<Role, { canPublish: boolean; canEdit: boolean }>
// perms.admin.canPublish is true (literal preserved)
// TypeScript warns if you forget a role or add an unknown field
The ultimate combo is pairing it with as const:
const ROUTES = {
home: "/",
blog: "/blog",
admin: "/admin",
} as const satisfies Record<string, `/${string}`>
type AppRoute = typeof ROUTES[keyof typeof ROUTES]
// AppRoute = "/" | "/blog" | "/admin" — literals, not string
as const freezes the values. satisfies validates them. Order matters: as const first, then satisfies — or the other way around depending on what you need to preserve.
04. infer in Conditional Types — extract types without reaching for any
When you're working with complex generics, you end up writing as any to "extract" the type from inside a wrapper. infer is the real solution.
The idea is to do pattern matching on the structure of a type and capture a piece of it:
// "If T is a Promise of something, capture that something as R"
type Awaited_<T> = T extends Promise<infer R> ? R : T
type A = Awaited_<Promise<string>> // string
type B = Awaited_<Promise<number[]>> // number[]
In real projects I use this to extract types from Server Actions without repeating myself:
type AsyncReturn<T extends (...args: never[]) => Promise<unknown>> =
T extends (...args: never[]) => Promise<infer R> ? R : never
async function getPosts(page: number): Promise<PaginatedResult<Post>> {
return prisma.post.findMany(...)
}
// If getPosts changes, this updates automatically — no manual type maintenance
type GetPostsResult = AsyncReturn<typeof getPosts>
// GetPostsResult = PaginatedResult<Post>
And with template literal types, infer becomes a substring extraction tool:
type RouteParam<T extends string> =
T extends `${string}:${infer Param}` ? Param : never
type BlogParam = RouteParam<"/blog/:slug"> // "slug"
type UserParam = RouteParam<"/users/:id"> // "id"
This is type-level programming. Use it with judgment — if the resulting type is harder to understand than the problem it solves, don't use it.
05. Exhaustive Check + noUncheckedIndexedAccess — the two flags that catch the most bugs
Exhaustive check: when you add a new value to a union and forget to update the switch.
type NotificationType = "comment" | "like" | "follow" | "mention"
// ❌ Without exhaustive check, TypeScript won't warn about the new case
function handle(type: NotificationType): string {
if (type === "comment") return "New comment"
if (type === "like") return "Someone liked your post"
if (type === "follow") return "New follower"
return "Notification" // "mention" falls here silently
}
The fix is an assertNever function that turns unhandled cases into type errors:
function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unhandled case: ${JSON.stringify(value)}`)
}
function handle(type: NotificationType): string {
switch (type) {
case "comment": return "New comment"
case "like": return "Someone liked your post"
case "follow": return "New follower"
case "mention": return "You were mentioned"
default:
return assertNever(type) // forget a case, get a compile error
}
}
noUncheckedIndexedAccess: turn this on in tsconfig.json and every array access or index signature becomes T | undefined:
// tsconfig.json: "noUncheckedIndexedAccess": true
const posts = ["post-1", "post-2"]
const first = posts[99] // string | undefined, not string
if (first !== undefined) {
console.log(first.toUpperCase()) // safe
}
It feels annoying at first — until you realize every posts[i].title you wrote without checking was a crash waiting to happen.
06. Combined Example — PostStateMachine
The first five patterns together, modeling the lifecycle of a blog post. Full code is in src/06-combined-post-machine.ts in the repo:
// 1. Branded types for IDs
type PostId = Brand<string, "PostId">
type AuthorId = Brand<string, "AuthorId">
// 2. Discriminated union for states
type Post =
| (BasePost & { status: "draft" })
| (BasePost & { status: "scheduled"; publishAt: Date })
| (BasePost & { status: "published"; publishedAt: Date; slug: string; views: number })
| (BasePost & { status: "archived"; archivedAt: Date; reason: string })
// 3. satisfies for transitions
const transitions = {
publish: (slug: string): Transition => (post) => ({ ... }),
archive: (reason: string): Transition => (post) => ({ ... }),
} satisfies Record<string, (...args: never[]) => Transition>
// 4. infer to extract transition names
type TransitionName = keyof typeof transitions // "publish" | "archive"
// 5. Exhaustive check in the renderer
function renderPost(post: Post): string {
switch (post.status) {
case "draft": return "✏️ Draft"
case "scheduled": return "⏰ Scheduled"
case "published": return `✅ /${post.slug}`
case "archived": return `📦 ${post.reason}`
default: return assertNever(post)
}
}
The result: an object that's impossible to put in an invalid state, with IDs that can't be swapped around, with validated transitions, and a renderer that fails at compile time if you forget a state.
The tsconfig that unlocks all of this
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true,
"verbatimModuleSyntax": true
}
}
You're already using strict: true. The other four options are what make the real difference. Turning them on in an existing project will surface errors — that's a good thing. Every error is a bug that didn't make it to production.
07. Result<T, E> — error handling without implicit exceptions
This pattern comes from Rust, and it's the one that most fundamentally changes how you write async code.
The problem with exceptions: functions that can fail don't say so in their signature.
// ❌ What happens if this fails? No way to know without reading the implementation.
async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
// The caller assumes it always works
const user = await getUser("u1") // can blow up, TypeScript won't warn you
With Result<T, E>, the error is part of the contract:
type Ok<T> = { readonly ok: true; readonly value: T }
type Err<E> = { readonly ok: false; readonly error: E }
type Result<T, E = Error> = Ok<T> | Err<E>
const ok = <T>(value: T): Ok<T> => ({ ok: true, value })
const err = <E>(error: E): Err<E> => ({ ok: false, error })
type UserError =
| { code: "NOT_FOUND"; message: string }
| { code: "NETWORK"; message: string }
async function getUser(id: string): Promise<Result<User, UserError>> {
const res = await fetch(`/api/users/${id}`).catch(e =>
err({ code: "NETWORK" as const, message: String(e) })
)
if (res instanceof Response && res.status === 404)
return err({ code: "NOT_FOUND", message: `User ${id} not found` })
// ...
}
// Now TypeScript forces you to handle both cases:
const result = await getUser("u1")
if (!result.ok) {
switch (result.error.code) {
case "NOT_FOUND": console.log("User not found"); break
case "NETWORK": console.log("Network error"); break
}
return
}
console.log(result.value.name) // TypeScript knows this is User
The mental shift is significant: instead of try/catch scattered across your codebase, errors travel as values. You can pass them, transform them, combine them. It's far more predictable.
// tryCatch wraps any function that might throw
function tryCatch<T>(fn: () => T): Result<T, Error> {
try { return ok(fn()) }
catch (e) { return err(e instanceof Error ? e : new Error(String(e))) }
}
// Chained validation pipeline
const result = tryCatch(() => JSON.parse(rawInput))
// { ok: true, value: {...} } or { ok: false, error: SyntaxError }
08. Type Predicates — teach TypeScript to narrow your types
TypeScript narrows automatically with typeof and instanceof. But for complex objects or data coming from outside the system, you have to teach it yourself.
// value is Post — the "type predicate" tells TypeScript what the value is
function isPost(value: unknown): value is Post {
return (
typeof value === "object" &&
value !== null &&
"slug" in value &&
"title" in value &&
typeof (value as Post).title === "string"
)
}
function processContent(raw: unknown): string {
if (isPost(raw)) return `Post: ${raw.title}` // TypeScript knows raw is Post here
return "Unknown"
}
The use case that changed my day-to-day the most is isDefined with array.filter:
function isDefined<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined
}
const rawPosts: (Post | null | undefined)[] = [post1, null, post2, undefined]
// ❌ BEFORE: filter(Boolean) returns (Post | null | undefined)[] — nulls still in the type
const bad = rawPosts.filter(Boolean)
// ✅ AFTER: filter with type predicate cleans up the type too
const clean = rawPosts.filter(isDefined)
// clean is Post[] — TypeScript knows, no casting needed
clean.forEach(post => console.log(post.title))
And assertion functions for when you'd rather throw than return false:
function assertIsPost(value: unknown): asserts value is Post {
if (!isPost(value)) throw new Error(`Invalid data: ${JSON.stringify(value)}`)
}
async function publishPost(rawData: unknown) {
assertIsPost(rawData)
// From here on, TypeScript knows rawData is Post — no if, no casting
console.log(`Publishing: ${rawData.title}`)
}
09. Mapped Types — transform a type's shape without copy-pasting
When you need variants of a type (readonly, nullable, optional fields, prefixed keys), the temptation is to copy-paste the interface. Mapped types let you describe the transformation once.
// { [K in keyof T]: ... } — "for each key of T, do something"
type Nullable<T> = { [K in keyof T]: T[K] | null }
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T
// Key remapping with `as` — rename keys during mapping
type AsyncGetters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => Promise<T[K]>
}
type UserGetters = AsyncGetters<{ id: string; name: string }>
// { getId: () => Promise<string>; getName: () => Promise<string> }
The most useful combo in practice: satisfies + mapped type for typed dictionaries where you don't want to lose your literals:
type PostStatusConfig = {
label: string
color: string
icon: string
}
const POST_STATUS = {
draft: { label: "Draft", color: "#9ca3af", icon: "✏️" },
scheduled: { label: "Scheduled", color: "#fbbf24", icon: "⏰" },
published: { label: "Published", color: "#00ff88", icon: "✅" },
archived: { label: "Archived", color: "#8b5cf6", icon: "📦" },
} satisfies Record<"draft" | "scheduled" | "published" | "archived", PostStatusConfig>
// satisfies checks that all states and all fields are present
// Values keep their literals — color is "#9ca3af", not string
type PostStatusKey = keyof typeof POST_STATUS
// "draft" | "scheduled" | "published" | "archived"
And for forms, deriving the form type from the model:
type FormFields<T> = {
[K in keyof T]: {
value: string
error: string | null
touched: boolean
}
}
// Form type is derived from the model — if User changes, UserForm changes too
type UserForm = FormFields<Pick<User, "name" | "email">>
The tsconfig that unlocks all of this
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true,
"verbatimModuleSyntax": true
}
}
You're already using strict: true. The other four options are what make the real difference. Turning them on in an existing project will surface errors — that's a good thing. Every error is a bug that didn't make it to production.
The repo with all examples compiling and an interactive runner is at github.com/JuanTorchia/typescript-patterns. Clone it, run npm run run to see everything in action, or open each file in your editor and break the examples to see how the compiler responds.
If you're just getting started with these patterns, the order I'd recommend: 01 → 05 → 07 → 08. Those four have the most immediate impact on real code. You'll naturally reach for the others when you need them.
Top comments (0)