TypeScript patterns that scale past a small team

April 14, 20265 min readBy Harman Kamboj
Full-stackEngineering

The TypeScript patterns that work for a solo project and the ones that survive a team of fifteen people touching the same code are not the same patterns. When it is just you, the compiler is a nice safety net you can ignore when it gets annoying. When ten people are merging into the same repo every day, the type system is the contract that keeps everyone from quietly breaking each other. I have watched both ends of that spectrum, and the habits below are the ones I keep coming back to.

Make illegal states impossible to represent

The single most valuable thing TypeScript gives you is the ability to model your domain so that bad combinations simply do not compile. Instead of an object with a status string and four optional fields where only some combinations are valid, use a discriminated union. A loading state has no data. A success state has data and no error. A failure state has an error and no data. Once you model it that way, the compiler forces every consumer to handle each case, and a whole category of undefined-is-not-a-function bugs disappears before runtime.

This is where types stop being annotation and start being design. When I review code from a teammate and I see a wide object full of optionals with a comment explaining which fields go together, I know we have a bug waiting to happen. The fix is almost always a union that makes the invalid shapes unrepresentable.

Validate at the boundary, trust inside

TypeScript types vanish at runtime. They tell you nothing about the JSON that actually arrived from an API, a webhook, or a database row. The pattern that scales is to validate untrusted data once at the edge with something like Zod, derive your type from that schema, and then trust it everywhere downstream. One source of truth, checked at the door.

The failure mode I see on growing teams is casting. Someone writes a type assertion to make the compiler stop complaining, and now there is a lie in the codebase that everyone downstream inherits. A cast is a promise you are making to the compiler with no enforcement behind it. On a small team you might get away with it. On a big one, every cast is a place where the types say one thing and reality says another, and the person who finds out is usually on call.

  • Parse external data with a schema, infer the type from it, never hand-write both
  • Treat `as` casts as a code smell that needs a comment justifying why it is safe
  • Turn on strict mode and noUncheckedIndexedAccess from day one, because retrofitting them later is brutal

Keep your clever types in a corner

TypeScript has a genuinely powerful type system, and that is a trap. You can write conditional types and mapped types and recursive template literal types that feel like a magic trick. Sometimes that machinery is the right call, usually inside a library where the complexity is hidden behind a clean public API. In ordinary application code it is almost always the wrong call.

The reason is simple. The next person to read that type, possibly you in six months, has to hold the whole derivation in their head to understand what shape they are dealing with. A type that takes ten minutes to decode is worse than a slightly repetitive type you can read at a glance. I have written the galaxy-brain version and regretted it more than I have ever regretted being a little verbose. Keep the wizardry contained, document it, and let application code stay obvious.

Share types across the boundary, do not duplicate them

On a full-stack codebase the TypeScript pattern that pays off most is making one type definition flow from the backend to the frontend. Whether you do that with tRPC, a generated OpenAPI client, or a shared package in a monorepo, the goal is the same. The frontend should not be guessing the shape of the API response from memory. When the backend changes a field, the frontend should fail to compile, not fail in production.

This is the thing that pays off most as a team grows, because it removes an entire class of coordination overhead. Nobody has to remember to update a separate type file. Nobody ships a mismatch because two definitions drifted. The compiler does the coordination that humans are bad at.

The pattern under all the patterns

Every habit here comes down to one idea: push correctness as far toward compile time as you reasonably can, and make the types describe reality rather than wishes. A small team can paper over gaps with shared context and a quick Slack message. A larger team cannot, because nobody holds the whole system in their head anymore. The type system becomes the shared memory.

None of this requires exotic TypeScript. Discriminated unions, schema validation at the edges, restrained generics, and shared types across the wire will carry you a very long way. The teams that struggle are usually not the ones who lack advanced tricks. They are the ones casting their way out of friction and letting the types slowly become fiction.

Building something where this matters?

I am open to senior full-stack, Web3, or AI engineering roles, fully remote and any timezone. If the hard part of your product is fighting you, that is the work I like.

Get in touch →