Enforcing Strict Null Checking in Legacy Codebases
Enforcing strict null checking in large legacy codebases prevents runtime errors like the infamous "cannot read properties of undefined," yet turning on strict compiler flags overnight is often impossible due to thousands of resulting errors. This article outlines practical strategies for incrementally adopting strict null checks without halting ongoing product development. By leveraging dual-configuration setups, automated baselining, branch-level CI gating, and smart refactoring tools, engineering teams can systematically eliminate null reference hazards across large, existing JavaScript and TypeScript applications.
1. Automated Error Baselining
Attempting to fix every null reference error upfront stalls feature delivery. Instead, automate the suppression of existing errors to establish a clean baseline.
Tools like ts-migrate or custom scripts can append
@ts-expect-error comments with specific error codes
directly above every line failing a strict null check. This allows the
compiler's strictNullChecks flag to be enabled globally in
tsconfig.json immediately. Because the existing errors are
silenced, CI pipelines remain green, while any newly introduced code
without appropriate null checks triggers a compiler failure.
2. Dual Configuration Strategy
For monolithic projects where modifying thousands of files with comments is undesirable, use two distinct configuration files:
tsconfig.json(Legacy Base): Leaves strict null checks disabled or scoped to core libraries to keep daily development unaffected.tsconfig.strict.json(Strict Base): Extends the base configuration but sets"strictNullChecks": trueand defines an explicitincludelist.
Teams maintain an explicit array of directories or files in
tsconfig.strict.json. When a module is audited and updated,
it is added to the strict file list. A CI step runs
tsc --project tsconfig.strict.json --noEmit to prevent
regressions in all migrated files.
3. Pre-Commit and PR Gating for Modified Files
Prevent legacy files from being edited without improving their type safety by enforcing strictness at the git boundary.
Using tools like lint-staged and Husky, run a script
against staged files during pre-commit or via pull request checks. The
script compiles only the changed files against strict null requirements.
If an engineer touches a legacy file to add a feature or fix a bug, they
are required to resolve the null checking errors in that specific file
before merging. Over time, high-churn areas of the application become
fully type-safe organically.
4. Gradual ESLint Rule Application
TypeScript compilation errors are binary, but linter rules can be applied incrementally using path overrides.
Configure the @typescript-eslint plugin with rules that
promote null safety, such as:
@typescript-eslint/no-non-null-assertion@typescript-eslint/strict-boolean-expressions@typescript-eslint/prefer-nullish-coalescing@typescript-eslint/prefer-optional-chain
Apply these rules as warnings globally, then upgrade them to errors
directory-by-directory using the overrides section of
.eslintrc. This isolates strict requirements to modern
submodules while maintaining visibility into legacy debt.
5. Codemods for Automated Remediation
Many strict null violations follow repetitive patterns, such as accessing nested properties or providing default values for nullable objects. Manually refactoring these is error-prone and time-consuming.
Run AST-based codemods (using tools like jscodeshift) to
automatically refactor common hazards:
- Convert verbose logical guards
(
a && a.b && a.b.c) into optional chaining (a?.b?.c). - Replace fallback assignments (
a || 'default') with nullish coalescing (a ?? 'default') where falsy bugs might occur. - Add explicit type assertions or early-exit guard clauses to repetitive function signatures.
6. Tracking Metrics and Preventing Regressions
Sustaining a long-term migration requires tracking progress and establishing hard stops against regression.
Implement a CI script that counts the total number of suppressed
errors (such as @ts-expect-error comments targeting
nullability) on every merge to the main branch. Log this count to an
internal dashboard. Configure CI to fail if a pull request increases the
total count of suppressed lines. This "ratchet" mechanism guarantees
that type coverage only moves in one direction: toward complete
strictness.