← Back to all posts

TypeScript patterns for apps that actually scale.

TypeScript can be a safety net or a straitjacket. The difference is in the patterns you reach for. These are the ones I've found worth their weight after five years of shipping TypeScript in production.

Close-up of code on a monitor with blue syntax highlighting

Most TypeScript codebases I've inherited share the same problem: they use TypeScript like JavaScript with extra annotations. Types are bolted on after the fact, generics are either absent or incomprehensible, and the type system catches typos but misses entire categories of bugs that it was designed to prevent. The patterns below are the ones that changed how I think about TypeScript — not as a linter, but as a design tool.

Stop typing everything as string

An email is not a string. A userId is not a string. A currency code is not a string. When everything is string, the type system can't tell you that you've passed a userId where an email was expected. The code compiles, the tests pass, and the bug ships to production.

Branded types fix this. The Brand<T, B> pattern creates nominal types from structural ones. You define type UserId = string & { readonly __brand: 'UserId' } and suddenly the compiler knows that a UserId and an Email are different things, even though they're both strings at runtime. Template literal types take this further — type HexColor = `#${string}` narrows the infinite space of strings to exactly the shapes you care about.

Discriminated unions over optional props

Instead of { type?: string; data?: object; error?: string }, use { type: 'success'; data: object } | { type: 'error'; error: string }. The first type permits impossible states — a response with both data and an error, or neither. The second makes illegal states unrepresentable.

The compiler becomes your documentation. When you write an exhaustive switch statement over a discriminated union, TypeScript tells you if you've missed a case. When a new variant is added six months from now, every switch statement in the codebase will produce a compile error until it's handled. That's not a type annotation — that's an automated code review.

Inference over annotation

Let TypeScript infer return types. Use as const for literal types. Use satisfies for validation without widening. The best TypeScript code has fewer type annotations than you'd expect, because the inference engine is doing the heavy lifting.

Over-annotating is a code smell. When you write const x: string = "hello", you're not adding safety — you're adding noise. When you annotate every function return type, you're fighting the compiler instead of leveraging it. Reserve explicit annotations for public APIs, function parameters, and the places where inference genuinely can't reach. Let the machine do what machines are good at.

Zod at the boundaries

Parse, don't validate. This is the single most important principle for TypeScript at scale. Inside your application, trust the types. At the boundaries — API responses, form inputs, environment variables, URL parameters — trust nothing.

Use Zod or a similar runtime validation library at every trust boundary. Define your schema once and derive the TypeScript type from it with z.infer<typeof schema>. Now your runtime validation and your compile-time types are always in sync. Once data crosses a trust boundary and is parsed by Zod, it's typed by the parser, not by faith. That distinction is the difference between a codebase that's type-safe and one that merely looks type-safe.

Generic functions that earn their complexity

Most generic code is over-engineered. A function with four type parameters and three conditional types might be technically impressive, but if the calling code needs to pass explicit type arguments to use it, you've failed. A generic function should make calling code simpler, not just the implementation clever.

Use generics only when the alternative is any or code duplication. If you're writing a utility that works on arrays of any type, generics are the right tool. If you're writing a function that only ever handles users and orders, just write two functions. The extra 10 lines of code are cheaper than the cognitive overhead of a poorly-motivated generic.

The goal of TypeScript is not to make every possible state representable. It's to make impossible states unrepresentable.

Closing thought

TypeScript is most valuable not when it catches typos, but when it encodes business rules. A type that says "this function only accepts verified emails" is documentation that never goes stale. A discriminated union that models your application's state machine is a specification that the compiler enforces on every commit.

Types that reflect your domain are the closest thing we have to living documentation. Write them with the same care you'd give to a public API, because that's exactly what they are.


↳ Keep reading

More from the blog.

— Stay in the loop

Like what you read?

Get an email when I publish something new. Roughly monthly, never spam.