We inherited a large TypeScript codebase with strict mode off. Turning it on revealed 400+ errors. Here's how we migrated incrementally, what the errors actually taught us about the code, and why it was worth the three-sprint investment.
The codebase had `"strict": false` in tsconfig. It had been that way since the project was bootstrapped two years earlier by a team under deadline pressure. By the time I joined as Tech Lead, the project had 80k+ lines of TypeScript that were, technically, not really TypeScript — they were JavaScript with type annotations and a very permissive compiler.
Running `tsc --strict` revealed 427 errors across 180 files. Here's what we learned and how we fixed it.
## What Strict Mode Actually Enables
`"strict": true` is a shorthand for six compiler flags:
- `strictNullChecks` — variables can't be null/undefined unless you say so
- `strictFunctionTypes` — function parameter types are checked contravariantly
- `strictBindCallApply` — bind/call/apply are properly typed
- `strictPropertyInitialization` — class properties must be initialized
- `noImplicitAny` — every untyped variable must be explicitly annotated
- `noImplicitThis` — this in functions must be typed
In our codebase, 80% of the 427 errors were from `strictNullChecks` alone. The codebase was riddled with unchecked nullable access.
## The Migration Strategy: Per-File Opt-In
Migrating all 427 errors at once was not an option — we had active feature work. The strategy: incremental migration using per-file `// @ts-strict` comments.
In TypeScript 5.0+, you can enable strict mode per file with a comment directive at the top. This lets you migrate file-by-file without changing the global tsconfig.
We added a lint rule (custom ESLint rule) that:
1. Blocked any new file from being created without the `// @ts-strict` directive
2. Reported a warning for any modified file that didn't have it
This meant the codebase trended toward full strict coverage over time, enforced by CI.
## What the Errors Actually Revealed
The most common pattern was this:
```typescript
// Before strict:
function getUser(id: string) {
return users.find(u => u.id === id);
// return type: User | undefined (but this was ignored)
}
// Consumer:
const user = getUser(id);
console.log(user.email); // Runtime crash if user is undefined
```
We had dozens of these. In most cases the fix was a simple null check. But in about 20% of cases, the undefined return was a real bug — the calling code had an incorrect assumption that the record would always exist. Strict mode didn't just add type annotations; it exposed actual logic errors.
## Patterns We Established
**Prefer explicit union returns over throwing.**
```typescript
// Instead of:
function findOrThrow(id: string): User {
const user = users.find(u => u.id === id);
if (!user) throw new Error("Not found");
return user;
}
// We standardised on Result types for expected failure:
type Result<T> = { ok: true; data: T } | { ok: false; error: string };
```
**Use `satisfies` for config objects.** The `satisfies` operator (TS 4.9) lets you validate that an object matches a type while preserving the literal type information. Enormously useful for config files.
**Discriminated unions for state.** We replaced boolean flag combinations (`isLoading`, `isError`, `data`) with proper discriminated union state types. This eliminated entire categories of impossible state bugs.
## Was It Worth It?
Three sprints of incremental migration work, spread across four months. The outcome:
- Production null reference errors in our error tracker dropped by ~60% in the two months after full migration
- Code review cycles got shorter — reviewers stopped needing to mentally track nullability
- Onboarding new engineers to the codebase became faster — the types serve as accurate documentation
The investment was real. But for a codebase that was going to be actively maintained for years, it was unambiguously the right call.