Incremental TypeScript Strictness: Migrating Without Breaking

Philip Rehberger Sep 11, 2026 6 min read

Roll out `strict`, `noUncheckedIndexedAccess`, and friends without freezing the team.

You have a TypeScript codebase, but it is not really TypeScript. strict is off, half the files have any annotations from an automated migration, and the team has stopped seeing TS errors as a signal because there are too many. The cleanup feels like a six-month project nobody has time for.

Incremental TypeScript strictness is the only realistic way out. You enable strict flags one at a time, fix the warnings each one produces, and stay shipping the whole way. Done right, it takes weeks to months — not the year a single big-bang migration would.

The Strict Flags Worth Caring About

TypeScript's strict: true is actually nine separate options. They are independent; you can adopt them one by one.

Flag What it catches
noImplicitAny Untyped parameters and variables
strictNullChecks Forgetting that values can be null or undefined
strictFunctionTypes Function parameter contravariance
strictPropertyInitialization Class properties used before initialization
noImplicitThis this of type any
alwaysStrict 'use strict' on every file
useUnknownInCatchVariables catch (e) where e: unknown instead of any
noUncheckedIndexedAccess arr[0] typed as T | undefined
exactOptionalPropertyTypes Distinguishes prop?: string from prop: string | undefined

The two with the biggest impact are strictNullChecks and noImplicitAny. Most existing TypeScript codebases have these on. The others vary.

The Order to Adopt

A sequence that works for most codebases:

  1. noImplicitAny if you do not have it. The biggest signal-to-noise ratio.
  2. strictNullChecks if you do not have it. The second biggest.
  3. useUnknownInCatchVariables — small impact, easy fix.
  4. noImplicitThis — small impact, easy fix.
  5. strictFunctionTypes — rarely fires once the above are in place.
  6. strictPropertyInitialization — class-heavy codebases will fight this. Use ! or definite assignment to ease in.
  7. noUncheckedIndexedAccess — high impact, real ergonomic cost. Do this last.
  8. exactOptionalPropertyTypes — niche, often produces noise without much benefit.
  9. alwaysStrict — usually a no-op in modern codebases.

Skip the niche ones if they do not improve your code. Strictness is a tool, not a goal.

The Per-File Migration Pattern

If your codebase is large enough that flipping a flag breaks hundreds of files, the per-file approach is the only realistic path.

Most modern build setups (Vite, Next.js, plain tsc) let you have per-file strictness via // @ts-strict comments or via inline tsconfig in newer TypeScript. The cleaner pattern: maintain two tsconfigs.

// tsconfig.json
{
  "compilerOptions": {
    "strict": false
  }
}

// tsconfig.strict.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "strict": true
  },
  "include": ["src/strict-files/**/*", "src/features/auth/**/*"]
}

Run both during CI. New code goes in strict-enabled directories. As old code gets touched, it gets converted and moved.

This is a real, sustained process — not a weekend project.

Tactics for the Noisy Flags

Handling strictNullChecks

This flag introduces hundreds of 'X' is possibly undefined errors. The fixes fall into a few patterns:

// Pattern 1: Use the non-null assertion when you know better than TS
const el = document.getElementById('app')!;
el.classList.add('ready'); // ! tells TS this is not null

// Pattern 2: Optional chaining + nullish coalescing
const name = user?.name ?? 'Anonymous';

// Pattern 3: Early returns
if (!user) throw new Error('User required');
console.log(user.name); // now TS knows user is non-null

The ! operator is a tool. Used sparingly, it is fine. Used everywhere, it is the new any. A rough heuristic: ! is okay when the assertion captures real knowledge (DOM elements that exist by contract); not okay when it is hiding a bug.

Handling noUncheckedIndexedAccess

This flag is the most disruptive to ergonomics. arr[0] is now T | undefined even if arr.length > 0. Common idioms break:

// Was fine, now errors
const first = items[0];
console.log(first.name); // first is possibly undefined

// Fixes
const first = items[0];
if (first) console.log(first.name);

// Or use .at() which is honest about it
const last = items.at(-1);

For codebases with lots of index access, this is genuinely painful. Adopt last, or accept the ergonomic cost as the price of safety.

The any Audit

Existing codebases have any annotations from automated migrations or "I'll fix it later." A useful first step is to ban new any while accepting existing ones.

// .eslintrc
{
  "rules": {
    "@typescript-eslint/no-explicit-any": "error",
    "@typescript-eslint/no-unsafe-assignment": "warn"
  }
}

New code cannot introduce any. Existing any can be tracked but not fail CI. Over time, the count drops as files get refactored.

For tracking progress, generate a count:

grep -rn "any" --include="*.ts" --include="*.tsx" src/ | wc -l

Watch the number trend down. Stalling means the discipline is slipping.

Working With Untyped Dependencies

Sometimes a dependency does not have types, or its types are wrong. Options:

// 1. Declare types locally
declare module 'untyped-pkg' {
  export function doThing(x: number): string;
}

// 2. Use DefinitelyTyped if available
npm i -D @types/lodash

// 3. Wrap the untyped boundary
import * as untyped from 'untyped-pkg';

interface ExpectedShape { /* ... */ }
const typed: { doThing: (x: number) => string } = untyped as any;

The wrapping pattern is most flexible. The risky type assertions live in one file; the rest of the code uses a clean, typed interface.

What to Stop Doing

A few patterns that prevent the migration from working:

  • as any to silence errors. This propagates type erasure. Track these in a backlog.
  • Generic Record<string, any>. Pick more specific types. If the shape is genuinely unknown, use Record<string, unknown>.
  • Type definitions that lie. A function annotated as (x: string) => number that occasionally returns undefined is worse than no annotation. Fix the type.
  • Whole files marked // @ts-nocheck. This excludes them from the migration entirely. Replace with per-line @ts-ignore or fix the real issue.

When You Are Done

The migration is "done" when:

  • strict: true is enabled globally
  • No // @ts-nocheck directives in production code
  • New code does not introduce any
  • The any count is trending down or zero

For most codebases, this takes a year of consistent effort. The reward is real: types catch bugs at compile time, refactoring is safer, and IDE support is dramatically better.

When It Is Not Worth It

If your codebase is small, in maintenance mode, or going to be rewritten in 18 months, the migration might not be worth completing. Adopt noImplicitAny and strictNullChecks (the high-value ones), skip the others, and call it good.

Perfect TypeScript strictness is a feature of long-lived codebases that benefit from compile-time safety. For short-lived code, the ROI is much lower.


Working on a TypeScript migration that has stalled out somewhere between "JS with types" and "real TypeScript"? We help teams plan incremental strictness rollouts that finish without freezing feature work. scopeforged.com

Share this article

Related Articles

Need help with your project?

Let's discuss how we can help you build reliable software.