TypeScript Best Practices for Large-Scale Applications
Strict mode, parsing at the boundary, and modelling states so illegal combinations cannot be represented — TypeScript that prevents bugs instead of annotating them.
TypeScript pays off in proportion to how seriously you take it. A codebase full of any and casts has all the build cost of TypeScript and almost none of the benefit.
Turn strict on now
strict: true from day one. Retrofitting it onto a large untyped codebase is genuinely painful, which is exactly why teams keep postponing it and never do it.
The flag people disable first is strictNullChecks, and it is the one that catches the most real bugs. Keep it.
Types are not validation
This is the mistake I see most often:
const user = await res.json() as User; // a lie, not a guarantee
That cast asserts a shape the compiler cannot verify. If the API changed last week, you now have a User that is not one, and the failure surfaces somewhere unrelated.
Parse at every boundary instead — network responses, form input, environment variables, URL params, anything crossing into your program from outside. Use a schema validator and derive the static type from the schema so the two cannot drift apart.
Make illegal states unrepresentable
Compare:
// Permits { loading: true, error: 'x', data: {...} } — meaningless.
type State = { loading: boolean; error?: string; data?: Data };
// Only valid combinations exist.
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'success'; data: Data };
The second version deletes an entire category of bug, and narrowing on status gives you exhaustiveness checking for free. This single pattern is the highest-value thing in TypeScript.
Stop primitive-obsessing on IDs
When UserId and OrderId are both string, nothing stops you passing one where the other belongs. Branded types close that hole cheaply:
type UserId = string & { readonly __brand: 'UserId' };
In a large codebase with many entity types, this catches a surprising number of real mistakes.
Use unknown, not any
any switches off the compiler and the effect spreads silently through everything it touches. unknown says the same thing honestly and forces a check before use. When you genuinely do not know a type, unknown is almost always what you meant.
Keep it readable
Type-level programming is fun and easy to overdo. A five-line conditional generic that nobody on the team can modify is a liability, not a flex. If a type needs a comment explaining what it does, consider whether a simpler type plus a runtime check would serve better.
Good TypeScript reads like documentation. That is the bar.
- TypeScript
- Best Practices
- Architecture