Migrating A Large Codebase To TypeScript Strict Mode Without Burning A Quarter
Flipping `strict: true` on a 200-file TypeScript codebase produces 8000 errors and a team revolt. The right migration is incremental, file-by-file, with the seven flags enabled in the right order. Here is the plan that has worked on three real codebases.
You inherit a TypeScript codebase that was started in a hurry. strict: false. Implicit any everywhere. Optional fields untyped. The team agrees strict mode is the right destination, sets strict: true in tsconfig.json on a Friday afternoon, and watches the build emit 8,000 errors. By Monday the flag is back to false and the migration is on the next sprint.
Strict mode is one of the highest-leverage changes you can make to a TypeScript codebase. It catches a class of bugs that no test will ever find. The trick is that you cannot flip it as a single switch on a 200-file codebase without grinding development to a halt. The right migration is incremental, with the strict flags enabled one at a time, in a specific order, with a clear “stop the bleeding” rule for new code.
This post is the plan I have run three times on real codebases. About three weeks of partial-time work each. No quarter-long projects, no pull-request bottlenecks.
What strict: true actually contains
strict: true is a meta-flag that turns on seven sub-flags. They are not equally hard to satisfy:
| Flag | Difficulty | What it catches |
|---|---|---|
noImplicitAny | medium | Function args / return types not annotated and not inferable |
strictNullChecks | hard | null / undefined slipping into places that expect a real value |
strictFunctionTypes | low | Variance issues in function parameters |
strictBindCallApply | low | Type errors in .bind() / .call() / .apply() |
strictPropertyInitialization | medium | Class properties that aren’t initialized |
noImplicitThis | low | this of type any in functions |
alwaysStrict | trivial | Emits "use strict" directives |
strictNullChecks is the one that finds bugs. It is also the one that produces the most errors. Most of the migration work is strictNullChecks.
The migration plan
Five steps, executed over two to three weeks. Numbered for a reason.
Step 1: Stop the bleeding. Configure ESLint to forbid implicit any and unsafe null behavior in new code, even though strict mode is still off globally:
// .eslintrc.json
{
"rules": {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/no-unsafe-member-access": "error",
"@typescript-eslint/no-unsafe-return": "error",
"@typescript-eslint/no-unsafe-call": "error"
}
}
Now CI fails on PRs that introduce new violations. The migration shrinks instead of growing while you work on it.
Step 2: Enable the cheap flags first. Update tsconfig.json:
{
"compilerOptions": {
"alwaysStrict": true,
"strictBindCallApply": true,
"strictFunctionTypes": true,
"noImplicitThis": true
}
}
These four together usually produce a few dozen errors. Fix them in a single PR. Quick win, no controversy, builds momentum.
Step 3: noImplicitAny next. This is medium difficulty. Run:
tsc --noEmit --noImplicitAny 2>&1 | tee implicit-any-errors.txt
wc -l implicit-any-errors.txt
Several hundred errors typically. Most are missing function-parameter annotations. Use ts-migrate-style transformations:
npx ts-migrate-full src/
ts-migrate (originally built by Airbnb) auto-adds explicit : any annotations to every implicit-any site, then leaves comments for you to fix one by one. The point is not to leave them as any forever. It is to silence the compiler so you can flip the flag, then improve the types incrementally.
Before merging, scan the diff for the : any annotations and replace the obvious ones ((req, res) in an Express handler should be (req: Request, res: Response), not : any). The not-obvious ones can stay for now.
Step 4: strictPropertyInitialization for class-heavy code. If you use classes (NestJS, TypeORM, Mongoose with classes), you’ll see errors like:
Property 'id' has no initializer and is not definitely assigned in the constructor.
Three options per property: assign in the declaration (id = ''), assign in the constructor, or use the definite-assignment assertion id!: string (sparingly; it is a pinky-promise to the compiler that you initialize this elsewhere). Decorators that initialize via metadata (TypeORM’s @PrimaryColumn) are common false positives, so use !.
Step 5: strictNullChecks. This is the one. Expect 70% of your remaining errors. The right order:
- Turn it on globally in tsconfig. Build will fail.
- Use TypeScript Project References or per-file flags to migrate one directory at a time without breaking the whole build.
- Start with utilities and shared libs. Code at the bottom of the dependency graph. Working those out first means changes ripple upward and reduce noise in app code.
- Move outward to feature modules. Each PR fixes one module.
- Make the build green. Celebrate.
Per-file migration with // @ts-strict-ignore
A trick that makes step 5 tractable: temporarily mark untouched files as exempt.
// @ts-strict-ignore
// (file is not yet null-safe; opt back in when migrated)
import { ... } from '...';
This requires the @strict-ignore plugin or a build setting that respects it. Alternatives:
// @ts-nocheck
…which disables all checks, not just strict-null. Nuclear, useful as a temporary measure.
The cleanest approach if your build supports it: enable strictNullChecks only for specific paths via tsconfig project references:
// tsconfig.strict.json, used for migrated code
{
"extends": "./tsconfig.json",
"compilerOptions": { "strict": true },
"include": ["src/utils/**/*", "src/lib/**/*"]
}
// tsconfig.json, used for the rest
{
"compilerOptions": { "strict": false }
}
A CI step compiles both. As you migrate a directory, you move it from the loose project to the strict one. Eventually everything is strict and the loose project disappears.
Patterns that come up over and over
Optional vs nullable. field?: string means the property may be missing. field: string | null means the property is always there but its value may be null. They are different. Pick one shape for your codebase and stick to it. I prefer T | null for “absent value” and ?: only for genuinely optional fields (like a config object’s options).
Nullable narrowing in functions. A function takes string | null and the body assumes string:
function greet(name: string | null) {
return `hello, ${name.toUpperCase()}`;
// ^ Object is possibly 'null'
}
Two clean fixes:
// 1. Reject null at the boundary.
function greet(name: string | null) {
if (name === null) throw new Error('name required');
return `hello, ${name.toUpperCase()}`;
}
// 2. Provide a default.
function greet(name: string | null) {
return `hello, ${(name ?? 'friend').toUpperCase()}`;
}
Avoid the third option (sprinkling name!.toUpperCase() everywhere); it silences the compiler without fixing the bug.
Array access returns T | undefined (with noUncheckedIndexedAccess). This sub-flag is not in strict: true but is hugely valuable:
const first = arr[0]; // T | undefined, not T
Enable it after you finish strictNullChecks. It catches off-by-one bugs the strict flags miss.
External library types are wrong. Some @types/* packages have inaccurate definitions: they claim something is non-null when it isn’t, or vice versa. Use type-fest helpers (SetRequired, SetOptional) to massage the imported type without forking it.
import type { Express } from 'express';
import type { SetRequired } from 'type-fest';
type Req = SetRequired<Express.Request, 'user'>; // claim 'user' is always set after auth
The PR cadence that works
A migration PR should follow three rules:
- One directory or one feature, no more. A PR that touches everything is unreviewable and merge-conflicts everyone else.
- No new logic. The migration changes types, not behavior. If you find a real bug, file a separate ticket and fix it in a separate PR.
- Tests run green. No skipping tests “because they are old.” If a test was relying on
any-typed mocks, the right fix is to type the mocks.
Aim for a daily rhythm during the migration: one engineer, one or two small PRs per day, reviewed within hours so they do not pile up. Three weeks of this gets a typical mid-size codebase to strict.
What to expect afterwards
Three things you will notice:
Refactors get faster. Renaming a field used to be a grep across the codebase; now the compiler tells you every site. The first significant refactor after the migration is the moment everyone on the team buys in.
A class of bugs disappears. “Cannot read property of undefined” in production logs drops dramatically. So do “TypeError: foo is not a function” stack traces. The compiler already caught those.
New code is faster to write, not slower. Counterintuitive but consistent. The autocomplete is sharper, the function signatures are more predictable, and you stop spending time debugging the kind of mistake the compiler now catches.
There is a small adjustment cost the first week. The compiler is louder than it was. After that, going back to a non-strict codebase feels like working in the dark.
The takeaway
Flipping strict: true on a Friday is how strict-mode migrations fail. Stop the bleeding with ESLint first. Enable the cheap flags as a single PR. Tackle noImplicitAny with auto-tooling and a follow-up cleanup. Save strictNullChecks for last and migrate per-directory with project references or per-file ignore comments.
Three weeks of part-time work. Tens of thousands of latent bugs caught by the compiler instead of users. The team that does this once never wants to work without strict mode again.
A note from Yojji
The kind of incremental refactor work that takes a noisy codebase to a strict, type-safe one, without grinding feature delivery to a halt, is exactly the kind of long-haul engineering Yojji’s teams have done across hundreds of client projects since 2016.
Yojji is an international custom software development company with offices across Europe, the US, and the UK, focused on the JavaScript ecosystem (React, Node.js, TypeScript) and cloud platforms (AWS, Azure, GCP). Their engineers ship the unglamorous codebase-health work (type migrations, test pyramids, dependency upgrades) alongside the feature delivery that pays for it.