skip to content

You set "strict": true in the tsconfig.json of an established TypeScript codebase and tsc reports several thousand errors. How do you get the project onto strict without a big-bang rewrite?

level: seniorimportance: should knowfreq 50%

answer

  1. it is eight problems, not one
  2. enable the cheap flags first
  3. no per-file pragma for strictNullChecks
  4. a second config with a growing file list
  5. expect-error, not ignore

basics

~20 s

Split the work by flag and by file. Enable the cheap members of the strict family first, then adopt strictNullChecks and noImplicitAny through a second tsconfig that extends the base, sets strict, and lists a growing set of already-clean files that CI checks alongside the loose build.

solid answer

~50 s

Treat the error count as eight separate problems, not one. Enable the family one flag at a time and measure: `alwaysStrict`, `noImplicitThis`, `strictBindCallApply` and `strictFunctionTypes` are usually a few dozen errors and can land immediately; `noImplicitAny` and `strictNullChecks` carry almost the whole count. `strictNullChecks` cannot be turned on per file — it changes the type of every declaration program-wide — so the working pattern is a second config, say `tsconfig.strict.json`, that extends the base, sets `"strict": true` and `noEmit`, and lists the files known to be clean. CI runs both; the list only grows, which turns the migration into a ratchet nobody can regress. Then work outward from shared modules, since types flow from them, and enforce suppression hygiene: `// @ts-expect-error` over `// @ts-ignore`, and no bulk `as any` or `!`, which produce a green build over the same bugs.

code

json · 11 lines
json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "strict": true,
    "noEmit": true
  },
  "files": [
    "src/util/date.ts",
    "src/util/money.ts"
  ]
}

go deeper

for a junior

Know that strict is several flags and that they can be enabled one at a time, and that silencing errors with any or an exclamation mark does not fix them. Ask which flag an error came from before fixing it.

for a middle

Be able to explain why strictNullChecks is program-wide and therefore cannot be enabled per file, and describe the second-tsconfig pattern with a growing file list checked by CI.

for a senior

Show the whole plan: measure per flag, land the cheap ones immediately, ratchet the expensive ones by file, work leaves-first through the dependency graph, and enforce suppression hygiene so the green build means something.

for a principal

Own the sequencing against delivery: what the migration costs, which flags are non-negotiable for new code, and how the ratchet is enforced so it survives reprioritisation. Decide when a partially-strict config is an acceptable long-term state versus a debt with a deadline.

## The error count is not the problem A five-figure error count from one boolean is the expected outcome, not a signal that the codebase is bad. `strict` is eight or more independent checks, and one of them — `strictNullChecks` — changes the type of every declaration in the program at once. The first move is to stop looking at the aggregate number and get a per-flag breakdown, because that is what decides the plan. ## Split by flag Enable one member at a time, on a scratch branch, and count: - `alwaysStrict`, `noImplicitThis`, `strictBindCallApply`, `useUnknownInCatchVariables` and `strictFunctionTypes` typically produce tens of errors in a mature codebase. They can often be fixed and merged the same day, and each one that lands is a check you never have to think about again. - `strictPropertyInitialization` is medium-sized and concentrated in classes, so it is bounded by how object-oriented the code is. - `noImplicitAny` and `strictNullChecks` are the two that produce thousands. Do them last, and do them differently. This alone often gets a project to "strict except two flags" in a week, which is a far better place to sit than "strict is off" — and the config says so explicitly: ```json { "compilerOptions": { "strict": true, "strictNullChecks": false, "noImplicitAny": false } } ``` ## Why `strictNullChecks` cannot be done per file There is no per-file pragma for it. The flag alters what `string`, `Foo` and every other type mean throughout the program, so a file compiled under it and a file compiled without it do not share a type system. Any "file by file" scheme must therefore work at the level of a *compilation*, not a directive. ## The growing-allowlist config The standard answer is a second tsconfig: ```json { "extends": "./tsconfig.json", "compilerOptions": { "strict": true, "noEmit": true }, "files": ["src/util/date.ts", "src/util/money.ts"] } ``` The main build stays as it is; CI additionally runs `tsc -p tsconfig.strict.json`. A file enters the list only when it and its dependencies check clean, and the list never shrinks. Because `files` pulls in each listed file's imports, the natural order of work falls out of the dependency graph: leaves first — shared utilities, domain types, constants — then the modules above them. Doing it in the other direction means re-fixing the same call sites as the types beneath them change. Where the repo is already split into packages, project references give you the same ratchet at package granularity, letting each package adopt `strict` on its own schedule. ## The alternative ratchet If a second config is awkward, the other workable mechanism is a baseline: record today's error count (or a checked-in list of erroring files) and fail CI if it increases. It is cruder — it does not stop a fixed file from regressing unless you track per file — but it is cheap and it stops the hole getting deeper while the team digs. ## Suppression hygiene The migration succeeds or fails on this point. `as any`, a non-null `!`, and `// @ts-ignore` all turn an error into silence, and a codebase that reaches "strict, zero errors" through them is strictly worse than before, because the config now claims a safety that is not there. - Prefer `// @ts-expect-error` with a short reason. It behaves like `@ts-ignore` except that it *errors when the line stops having an error*, so suppressions delete themselves as the underlying types improve. Every one is a to-do the compiler tracks for you. - Ban bulk codemods that insert `!` or `as any`. A hand-written narrowing at each site is the actual work; automating it away is skipping it. - Treat `// @ts-nocheck` on a whole file as a last resort with an owner and a date. ## Sequencing the human side Make the strict list a required check so new files start clean — that is the highest-value single step, because greenfield code is free to write correctly and expensive to retrofit. Give the migration a visible counter (files on the list, of total). Fold conversions into work already touching the file rather than running a separate, competing workstream; a file being modified is being tested anyway. ## What done looks like Done is `"strict": true` in the single base config, no per-flag overrides, no `@ts-expect-error` left that hides a real modelling gap, and the second config deleted. Reaching it in stages is normal; what matters is that every stage is enforced by CI rather than by intention.

  • What does // @ts-expect-error give you that // @ts-ignore does not?
    It reports an error when the line it precedes no longer has an error. That inverts the maintenance burden: an ignore comment survives forever and silently protects code that may have been fixed years ago, while an expect-error comment fails the build the moment it becomes unnecessary, so suppressions clean themselves up as the migration progresses.
  • Why is fixing shared utility modules before feature code the right order?
    Because types flow outward from them. If a shared helper's return type is still `any` or implicitly nullable, every consumer you fix is fixed against a lie and has to be revisited when the helper is corrected. Fixing leaves first means each layer is typed against something already true, so the work is done once.
  • How do you stop the migration from regressing while it is still in progress?
    Make the strict compilation a required CI check with an allowlist that only grows, so a file that has been converted cannot silently fall back. Add new files to it by default. If you use an error-count baseline instead, track it per file rather than as a total, otherwise fixing one file quietly pays for breaking another.

saying these in an interview costs you the question

  • Turns strict on and silences errors with as any or !
  • Thinks strictNullChecks can be enabled for one file with a comment
  • Treats the error count as one problem instead of per-flag
  • Runs the migration as a separate branch that drifts for months
  • Uses @ts-ignore everywhere and calls the project strict

context