Modern Web · Typescript

TypeScript Strict Mode — A Field Guide

What tsconfig.json's strict: true flag actually turns on — strictNullChecks, noImplicitAny, strictFunctionTypes and the rest — and a practical, incremental path for migrating an existing codebase to it without a big-bang rewrite.

John Kihiu12 min read

Turning on strict: true in tsconfig.json is not one setting — it is a bundle of eight separate flags, and on a codebase that grew up without them, flipping the switch can produce thousands of errors on the first compile. I've done this migration on a few different codebases now, and the pattern that works is always the same: understand what each flag actually checks, then migrate flag-by-flag instead of all at once. This is the guide I wish I'd had the first time.

What strict: true actually turns on

strict is not a real compiler check by itself — it's shorthand that enables a fixed set of individual flags. If any of them are explicitly set to false in your config, that override wins even with strict: true, which is exactly the mechanism the incremental migration below relies on. The bundle is:

JSON · tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    /* equivalent to explicitly setting all of:
       strictNullChecks, noImplicitAny, strictFunctionTypes,
       strictBindCallApply, strictPropertyInitialization,
       noImplicitThis, alwaysStrict, useUnknownInCatchVariables */
    "target": "ES2022",
    "module": "ESNext",
    "skipLibCheck": true
  }
}

Why the all-at-once migration fails

The naive move is to add "strict": true and start fixing errors top to bottom. On a small project this works fine. On anything with a few years of history, it produces an error count in the thousands, most of them strictNullChecks violations cascading through the same handful of shared utility functions. Nobody can land that in one PR, so it sits on a branch, drifts out of date as `main` moves on, and eventually gets abandoned. The fix isn't more willpower — it's migrating one flag at a time, in the order that isolates the pain.

Migrating flag by flag

Instead of enabling strict directly, enable the individual flags in compilerOptions one at a time and land each as its own PR. A reasonable order:

  1. noImplicitThis and alwaysStrict first — low error counts, almost no design decisions involved, good for building momentum.
  2. noImplicitAny next — every error is "add a type annotation here." Tedious but mechanical; you can often let the TypeScript language service auto-insert the inferred type.
  3. strictNullChecks — do this on its own, after the above two are clean. This is where the real design decisions live: is this property actually optional, or did we just never initialize it? Budget the most time here.
  4. strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, and useUnknownInCatchVariables last — by this point strictNullChecks has usually already forced most of the fixes these flags would otherwise catch, so they tend to close out with far fewer surprises.

Once every individual flag is enabled explicitly, you can delete them all and replace them with "strict": true — at that point it's a no-op, and you've locked in that future flags added to the strict bundle (TypeScript does add new ones in major versions) get picked up automatically.

Let tsc do the counting

Before starting, run tsc --noEmit --strict 2>&1 | wc -l (or pipe through grep -c "error TS") to get a real error count per flag. Toggling one flag at a time and re-running gives you an honest sense of scope before you commit to an order or a timeline.

Firewalling unmigrated code with @ts-expect-error

For files you can't fix immediately — a vendored module, a file owned by another team, code scheduled for deletion — // @ts-expect-error is the right tool, not // @ts-ignore. The difference matters: @ts-ignore silently suppresses whatever error is on the next line, even after the underlying code changes and a new, different error appears. @ts-expect-error does the same suppression, but it also errors if the line it's attached to stops producing an error — which means if someone fixes the type properly later, the now-useless suppression comment gets flagged and can be deleted, instead of rotting in the codebase forever.

TypeScript
class UserSession {
  // strictPropertyInitialization would flag this without
  // a default, a constructor assignment, or a "!" assertion
  private userId!: string;

  init(id: string) {
    this.userId = id;
  }
}

function getConfigValue(key: string): string {
  const value = process.env[key];
  // @ts-expect-error - legacy call site still assumes env vars
  // always exist; TODO(migration): audit and add null handling
  return value.trim();
}

try {
  riskyParse(input);
} catch (err) {
  // useUnknownInCatchVariables means err is `unknown`, not `any`
  if (err instanceof Error) {
    console.error(err.message);
  } else {
    console.error("Unknown error", err);
  }
}

A per-directory escape hatch works the same way at scale: keep a short list of files still under migration (a lint rule or a simple script checking for a marker comment works fine), and fail CI if a file not on that list contains an unexplained suppression comment. That keeps the debt visible and shrinking instead of quietly growing.

Don't let noImplicitAny hide behind any[]

A common escape valve during migration is typing everything troublesome as any to make the error go away. That defeats the purpose — any disables checking for that value everywhere it flows, not just at the declaration site. If you need an honest "I don't know this type yet" placeholder, prefer unknown and narrow it at the point of use; it forces a decision instead of deferring one indefinitely.

Wrapping up

strict: true is eight flags wearing one name, and treating it as a single on/off switch is what makes migrations feel impossible. Split it back into its parts, land noImplicitAny and the small flags first, spend your real budget on strictNullChecks, and use @ts-expect-error with a paper trail — not @ts-ignore — to firewall what's left. The codebase ends up fully strict without ever needing a single PR that touches everything at once.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.