Engineering
TypeScript strict mode: lessons from 200,000 lines of code
Two years of TypeScript strict mode on a 200,000-line codebase. What we learned, what we wish we had known, and the rules we follow.
TypeScript strict mode catches real bugs, not theoretical ones. After two years and 200,000 lines of strict TypeScript, here is what we learned.
Which strict flags matter
TypeScript strict mode is a bundle of flags. Not all of them are equally valuable.
Enable:
- strictNullChecks. Catches the largest class of bugs. Without this, null and undefined slip through silently.
- noImplicitAny. Forces you to type every parameter and return value.
- strictFunctionTypes. Catches bivariant function parameter checks.
- noImplicitReturns. Forces every code path to return.
- noFallthroughCasesInSwitch. Catches missing breaks.
- noUncheckedIndexedAccess. Treats array and object index access as T plus undefined.
Skip or set to false:
- noImplicitThis. Low value, catches edge cases that almost never happen.
- strictBindCallApply. Low value, same.
- alwaysStrict. The default, no need to set explicitly.
The three flags that catch 80 percent of bugs: strictNullChecks, noImplicitAny, strictFunctionTypes. Start there.
Discriminated unions are the most powerful feature
Discriminated unions are how we model state in TypeScript.
The pattern:
type State = | { kind: idle } | { kind: loading } | { kind: ok; data: T } | { kind: error; error: string };
TypeScript narrows the state variable automatically inside each case. The data property is only available in the ok case. The error property is only available in the error case. Forget a case and the function does not compile.
We use this pattern for every stateful piece of UI. It scales to 200,000 lines without breaking.
Avoid the any type
any disables type checking. It is contagious: if a function takes any, its return is any, and the bad type spreads.
Use unknown instead:
function parseUserInput(input: unknown): User {
if (typeof input !== object || input === null) {
throw new Error(Invalid input);
}
const obj = input as Record
unknown forces you to narrow before using. The result: every external input (API responses, parsed JSON, form data) is checked before use.
Type-only imports
Use import type to remove the import at runtime. This keeps bundle size small and avoids circular dependencies.
Rule: if you only use the import as a type, use import type. The TypeScript compiler will error if you try to use it as a value.
Branded types prevent ID mixups
Two IDs that are both strings get mixed up. UserId and OrderId are different concepts. Branded types prevent the mixup:
type UserId = string and { readonly brand: unique symbol }; type OrderId = string and { readonly brand: unique symbol };
At runtime, both are strings. At compile time, TypeScript treats them as different. This catches real bugs in large codebases.
Type tests for libraries
If you publish a library, type tests catch regressions in your types. Use expectTypeOf or tsd.
If you accidentally change the signature, the type test fails in CI. The library consumer is protected.
Patterns that scale
- Discriminated unions for state.
- Branded types for IDs.
- unknown for external inputs.
- Type-only imports for shared types.
- Result types for fallible operations: { ok: true; value: T } or { ok: false; error: string }.
Patterns that break
- Class hierarchies. Prefer discriminated unions over class hierarchies for state.
- Type assertions (as Type). Use them sparingly.
- Mixins. Use composition over mixins.
- Function overloads. They are powerful but hard to maintain.
The one rule that matters most
If a function takes external input, the first parameter is unknown. Narrow inside the function. Return a typed value.
This one rule catches the majority of bugs in our codebase. Every external input is checked at the boundary. Inside the function, everything is typed.
Questions
Frequently asked questions
Common questions about typescript strict mode: lessons from 200,000 lines of code, answered plainly.
Was this helpful?
One tap. No email, no signup.