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.
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.