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": true and defines an explicit include list.

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.