A step-by-step strategy for grown codebases instead of a big-bang rewrite
A project that has grown for years without strictNullChecks will very likely have hundreds or thousands of spots where null or undefined are not handled. Flipping strictNullChecks on in such a project usually breaks the build immediately with an overwhelming list of errors, which is why a gradual, folder-by-folder migration is almost always the only realistic path.
Table of Contents
- 1. Why flipping the switch directly usually fails
- 2. Opt-in instead of opt-out: enabling strict per file
- 3. ts-migrate and similar tools for the first pass
- 4. The four most common error classes and how to fix them
- 5. The non-null assertion operator as a controlled valve
- 6. Prioritizing modules: where to migrate first
- 7. A CI gate against regressions
- 8. Team coordination during a longer-running migration
- 9. Realistic timelines and success criteria
- 10. Summary
- 11. FAQ
1. Why flipping the switch directly usually fails
strictNullChecks fundamentally changes the meaning of null and undefined: without the option both values are implicitly part of every type, with the option they must appear explicitly in the signature. In a codebase of several tens of thousands of lines, turning it on often produces several thousand new errors at once, because every spot where a value could theoretically be null is affected.
A big-bang attempt to fix every error in a single pull request blocks the team for days or weeks and almost always results in a huge, barely reviewable diff. A more realistic strategy is one where strictNullChecks applies immediately to new files, while existing code is migrated gradually, file by file or folder by folder.
2. Opt-in instead of opt-out: enabling strict per file
The most pragmatic starting point is enabling strictNullChecks globally in tsconfig.json, but excluding all not-yet-migrated files through a separate, less strict configuration. TypeScript supports this via project references or via a second tsconfig that either explicitly lists the migrated files or includes them through a glob pattern.
An alternative, often simpler path in practice is a comment at the top of a file that a custom ESLint rule or a dedicated script evaluates to track which files already count as migrated. In both cases it matters that new files are mandatorily strict from day one of the migration, so the number of files still needing migration does not keep growing while work continues on the backlog.
// tsconfig.json -- base configuration with strictNullChecks on globally
{
"compilerOptions": {
"strictNullChecks": true,
"strict": false
},
"include": ["src"]
}
// tsconfig.legacy.json -- for not-yet-migrated directories
{
"extends": "./tsconfig.json",
"compilerOptions": { "strictNullChecks": false },
"include": ["src/legacy-module-a", "src/legacy-module-b"]
}
3. ts-migrate and similar tools for the first pass
Airbnb's ts-migrate automates a large part of the first pass: the tool inserts a @ts-expect-error comment with a unique error reference at every error site, so the build turns green again immediately without anyone manually fixing every error. This turns an unsolvable blocker into a prioritizable list of TODOs.
The catch with this approach: @ts-expect-error only suppresses the error, it does not resolve it. Without a disciplined second step that systematically removes those comments again and adds the actual null checks, the codebase stays permanently in a half-migrated state with hundreds of silenced errors nobody touches anymore.
# Apply ts-migrate to a single directory
npx ts-migrate migrate src/legacy-module-a
# Count remaining @ts-expect-error suppressions
# to track migration progress
grep -r "@ts-expect-error" src/legacy-module-a --include="*.ts" | wc -l
4. The four most common error classes and how to fix them
Most errors after enabling strictNullChecks fall into a handful of patterns: missing null checks before property access, function parameters that were implicitly optional, indexed array access which TypeScript treats as guaranteed non-undefined by default, and outdated type guards that do not exclude null.
For indexed array access it is also worth enabling noUncheckedIndexedAccess, which correctly types arr[i] as T | undefined instead of T, a gap that strictNullChecks alone does not cover, since TypeScript assumes index access is safe by default for compatibility reasons.
// Error class 1: missing null check before property access
function getCity(user: User | null): string {
return user.address.city; // Error: user could be null
}
// Fix:
function getCityFixed(user: User | null): string {
if (user === null) throw new Error("No user");
return user.address.city;
}
// Error class 2: implicit optional parameter
function greet(name: string = null) {} // Error since strictNullChecks
function greetFixed(name: string | null = null) {}
// Error class 3: array index without noUncheckedIndexedAccess
const first = users[0].name; // "safe" without the option, but it is not
5. The non-null assertion operator as a controlled valve
The ! operator suppresses a null warning without adding an actual runtime check, which makes it a dangerous tool when applied unreflectively everywhere the compiler complains. During a migration the temptation is strong to silence every error with !, which formally completes the migration but destroys the actual protection against runtime errors.
A sensible compromise is allowing ! only where the non-null guarantee is clearly evidenced by context, for example right after a preceding existence check, and preferring real guards or default values everywhere else. A lint rule that limits the number of new ! occurrences per pull request helps enforce that discipline.
6. Prioritizing modules: where to migrate first
Not every module is equally important for a migration. Core domain logic with many internal consumers and high change frequency benefits most from real null safety, because bugs there cause the most damage and the most frequent code changes happen there. Pure UI components with little business logic can often be pushed to the end.
A pragmatic prioritization criterion is combining error count per file with change frequency from the git history: files with few errors and a high change rate are favorable first candidates, because they migrate quickly and every subsequent change already happens in safe mode.
7. A CI gate against regressions
Without technical enforcement, new non-strict code easily slips back into already-migrated areas, especially with multiple developers working in parallel. A CI step that reconciles the list of migrated files against the actual error count with strictNullChecks forced on prevents the error count in already-checked-off areas from silently creeping back up.
A simple script counts errors per file with strictNullChecks forced on and compares the result against a checked-in baseline file, an increase fails the build, a decrease automatically updates the baseline. This makes progress measurable and regression technically impossible.
# Simplified CI gate script
tsc --strictNullChecks --noEmit 2> errors.txt
CURRENT=$(wc -l < errors.txt)
BASELINE=$(cat null-check-baseline.txt)
if [ "$CURRENT" -gt "$BASELINE" ]; then
echo "New strictNullChecks errors introduced: $CURRENT > $BASELINE"
exit 1
fi
8. Team coordination during a longer-running migration
A migration spanning several weeks or months needs visibility: a central dashboard or a regularly updated list of which modules are already strict prevents duplicate work and makes progress visible. Without that visibility, migration work experience shows gets lost quickly among competing priorities.
It pays off to break the migration into small, dailly closable units, such as a single file or a small feature module per pull request, rather than migrating large directories in one go. Smaller pull requests are easier to review and reduce the risk of a merge conflict blocking a half-finished migration.
9. Realistic timelines and success criteria
How long a strictNullChecks migration takes depends heavily on codebase size and the original level of discipline, several weeks of continuous but not full-time work is realistic for mid-sized projects. More important than a fixed date is a declining trend in the error count and a CI gate that prevents new code from making the problem worse.
In the end what counts is not just that the compiler no longer reports errors, but that the remaining non-null assertions were placed deliberately and documented, rather than left over as error suppression from the migration phase.
| Approach | Effort | Risk | Suited for |
|---|---|---|---|
| Big-bang switch | very high, short-term | high, team blocked | very small codebases |
| ts-migrate with @ts-expect-error | low initially, high later | medium if left unattended | forcing a quick green build |
| Folder-by-folder opt-in | moderate, spread out | low, controllable | medium to large codebases |
| CI gate with baseline | low, one-time setup | very low | any ongoing migration |
Mironsoft
TypeScript migration, type safety, and team onboarding
A JavaScript codebase without type safety, but no time for a full migration?
We migrate existing JavaScript projects to TypeScript step by step, set up strict compiler settings cleanly, and bring teams to the same type-safety level with code reviews and style guides.
Migration Roadmap
Plan and execute a gradual JS-to-TS migration without big-bang risk.
Strict Mode Rollout
Set up tsconfig.json, ESLint rules, and CI checks for lasting type safety.
Team Onboarding
Bring developers up to speed on TypeScript best practices with workshops and reviews.
10. Summary
strictNullChecks migration
Strategy
Folder-by-folder opt-in instead of a global big-bang switch
Tooling
ts-migrate for @ts-expect-error as initial relief
Safeguard
A CI gate with an error baseline prevents regressions
Prioritization
Core domain logic with high change rate goes first