Keeping TypeScript Sane at Scale
How to stop a large TypeScript codebase from rotting: strictness, project references, boundaries, and types that actually catch bugs.
The day TypeScript stops helping
Scaling TypeScript across a large codebase is where most teams quietly lose the value they signed up for. The compiler still runs, the editor still autocompletes, but any creeps in at the edges, builds slow to a crawl, and a green type-check stops meaning the code is correct. At BSH we have inherited enough of these projects to know the rot is rarely one bad decision — it is a hundred small concessions made under deadline. The good news is that the fixes are mechanical, and you can apply them incrementally without a rewrite.
Turn on the strictness that pays for itself
The single highest-leverage change is the compiler configuration. Many teams run TypeScript with strict mode half-disabled and never revisit it. Three flags do most of the work:
- strict — the umbrella flag. If you turn on nothing else, turn on this. It enables strict null checks, which alone eliminate a whole category of runtime crashes.
- noUncheckedIndexedAccess — makes array and record access return T or undefined. Noisy at first, but it surfaces the exact off-by-one and missing-key bugs that slip past code review.
- exactOptionalPropertyTypes — stops undefined from silently satisfying an optional property, which matters once you serialise objects across a network boundary.
Switching these on for an existing codebase floods you with errors. Do not fix them all at once. Enable the flag, let the build fail only on changed files using a lint rule, and burn the backlog down over sprints.
Make the type system express your domain
A type that mirrors a database row is barely worth writing. The types that earn their keep encode rules the compiler can enforce. A few patterns we reach for repeatedly:
- Discriminated unions over optional soup. A loading state with isLoading, data, and error as three independent optionals has eight possible shapes, most of them invalid. A union of three variants has exactly three.
- Branded types for identifiers. A UserId and an OrderId are both strings at runtime, but branding them stops you passing one where the other belongs — a bug that is otherwise invisible until production.
- Parse, do not validate. Validate untrusted input once at the boundary with a schema library, and let the rest of the codebase work with types it can trust. Re-checking the same data five layers deep is wasted effort and a sign the boundary is in the wrong place.
If a reviewer has to read the implementation to know whether a call is safe, the type was not doing its job.
Keep the compiler fast
Type-checking time is a tax every developer pays on every change, and a slow tsc is how teams start skipping it. Project references are the structural fix: split the codebase into composite projects so the compiler only rechecks what changed and caches the rest in a build-info file. Pair that with the incremental flag and a cache path that survives between runs, and a re-check of unchanged code drops from a minute to a couple of seconds. For editor responsiveness, keep individual files focused — a single module exporting fifty types is slower to load than five modules exporting ten.
Put walls between your modules
Types alone do not stop a UI component from importing directly out of a data-access layer; that takes enforced boundaries. We organise by feature rather than by file type, and add a lint rule that forbids cross-feature deep imports — modules talk to each other only through a public entry point. This is the codebase equivalent of encapsulation, and it is what keeps a refactor in one corner from rippling across the whole tree. The discipline matters more as the team grows; a convention that lives only in a senior engineer's head does not survive onboarding the fifth developer.
Treat any as a leak to be plugged
Every any in the codebase is a hole in the net, and the holes spread: a value typed as any infects everything it touches, because operations on it produce more any. Banning the keyword outright with a lint rule is the blunt instrument that works. When you genuinely do not know a shape — data fresh off the network, a value from an untyped library — reach for unknown instead, which forces a narrowing check before you can use it. That one substitution moves the validation to the boundary where it belongs and keeps the trusted interior honest.
- Forbid implicit any with the compiler, and explicit any with a lint rule, so neither slips in unnoticed.
- Type third-party libraries that ship no types with a small local declaration rather than casting at every call site.
- When you must assert a type the compiler cannot verify, isolate the assertion in one well-named function so the unsafe step is visible and auditable.
The aim is not zero pragmatism — it is making every escape hatch deliberate, named, and rare, so a reviewer can see exactly where the type system was overridden and why.
How BSH can help
We do this work on real systems for clients across Thrissur and far beyond — auditing TypeScript health, tightening compiler settings without breaking the build, and carving monoliths into bounded modules teams can actually move in. If your type-check has stopped catching bugs or your build has become the slowest part of your day, BSH Technologies can help you get the leverage back. Reach out and we will start with an honest assessment, not a rewrite pitch.
From the blog
View all postsDesigning Multi-Tenant SaaS That Scales
Choosing an isolation model, keeping tenant data separate, and dodging the noisy-neighbour and migration traps that bite SaaS later.
Hitting Green Core Web Vitals in Next.js
A practical guide to LCP, INP and CLS in Next.js — image handling, font loading, the App Router boundary, and costly third-party scripts.