407 TypeScript errors to zero, with no suppressions

Note · TypeScript~7 min read · TypeScript · React 19 · NestJS · October 2026

A glass 3D counter ticking down from a red 407 to a green zero

On 31 August 2026 the frontend of Asset Vault, the largest codebase I own, carried 407 strict TypeScript errors, and because the build script was tsc -b && vite build, it produced nothing at all. A week later it was at zero, the backend's 12 were at zero, and CI gated on both. No any, no @ts-ignore, no @ts-expect-error, no compiler flag loosened: strict, noUnusedLocals, noUnusedParameters and verbatimModuleSyntax all stayed on. The count was never the point. The point was what the errors turned out to be describing.

First, make the debt visible without blocking the app

Vite strips types without checking them, so an app with 407 type errors bundles and runs fine. Chaining tsc -b in front of it turned a type debt into "the project cannot be built from a clean clone", which is a different and worse problem. The first commit split the scripts: build is vite build, typecheck is tsc -b --force, and build:strict keeps the old chain for anyone who wants it. CI ran the type-check and reported the count without gating on it, so the number sat in every run where it could be driven down instead of sitting red and ignored. The same commit removed 46 genuinely unused imports (407 to 361), and the bundle came out byte-identical, which is the evidence that nothing load-bearing went.

The command that lies

A week in, I discovered why the debt had been invisible for so long. The frontend uses TypeScript project references: the root tsconfig.json lists references and no files of its own. So the familiar command and the correct one disagree completely:

  • tsc --noEmit -p tsconfig.json reports 0 errors and exits 0. It type-checks nothing.
  • tsc -b --force reported 74 errors at that moment.

Anyone reaching for the obvious invocation gets a clean bill of health for a project that does not have one. I hit this myself, which is why it is written in the README and in the commit rather than just known. The typecheck script also lacked --force, so a warm incremental cache could return a stale count; both halves now force a full build and report the same number from a cold or warm cache.

Rules for the mechanical passes

About a third of the errors were unused declarations, and those look safe to clear in bulk. They are not. The first pass took 41 of them (358 to 317) and deliberately left 84 alone, with the reasoning in the commit body:

  • Unused named imports can be dropped from an import statement without changing runtime behaviour, unless dropping them would empty the braces, because deleting the whole statement also deletes the module's side effects. Those were skipped.
  • Unused parameters are prefixed with _, never deleted. Several were positional callbacks for a charting library. Deleting value from (value, name, props) silently rebinds name to the first argument and draws a plausible chart with the wrong labels.
  • Destructured bindings were left for one-at-a-time review. { enableColumnReordering = false } names the property being read; prefixing it reads a different, non-existent prop and silently swaps in the default. Each of those had to be judged as dead code or unfinished code.

Every commit in the chain carries its before and after count in the subject line, and every one was verified by re-running the type-check, the production build and the test suite before the next began.

The errors were a bug list written by the compiler

Suppressing an error says "I know better than the checker". On this codebase, at least six times, the checker knew better than the code:

  • A dashboard that never refreshed. It subscribed to the mutation cache and tested event.type === 'success'. React Query v5 emits only added, removed and updated from that cache, so the condition could never be true and editing an invoice left the dashboard showing old totals until something else refetched.
  • A ReferenceError waiting inside a catch block. logger was used without being imported in the inventory API, so the first failed bulk delete would have thrown from the code meant to report the failure.
  • A button that cleared nothing. The "clear recent searches" chip passed its handler straight to onClick, so the handler received a mouse event as its context argument; context === 'global' was false and the button did nothing while looking like it worked.
  • An inactive state that rendered as active. color="default" is v4 syntax for the component library in use; v5 drops the prop, so the filter button looked identical on and off.
  • A rollback that could insert an empty row. The optimistic-update path spread a possibly-undefined object, and {...undefined} is {}.
  • A hook called inside a ternary. Both argument shapes carried the field the hook needed, so the branch calling it was typed never; a rules-of-hooks violation for a branch that could not run.

On the backend, the twelve errors were all in integration specs, and each was a genuine defect in the test: a variable name declared nowhere, a fixture setting a field the entity no longer has, null where the type says undefined. Twenty-one widened string literals were pinned with as const rather than cast to the DTO, because the cast would have compiled equally well while switching the checking off. One real tension was recorded instead of fixed: the entity says storeId?: string but the column is nullable and the database really does return null; correcting the entity is a production type change with a wide blast radius, and a test fixture is the wrong place to make it.

Zero, then gate

The last twelve frontend errors went on 7 September; three of them were the live UI bugs above. The same afternoon the CI step stopped tolerating failures, with a note kept in the workflow about why the hand-written command must not be used. The gate earned itself within hours: that evening a pull request removing a feature orphaned seven imports and briefly took the frontend to seven errors, and the gate would have refused it. The fix landed in the same commit, and the count has been zero since.

The takeaway

A type error count is not a code quality score, and driving it to zero is not the achievement. The achievement is reading every error as a sentence the compiler wrote about your program and deciding, one at a time, whether it is telling the truth. On this project it was telling the truth about a dashboard, a catch block, two buttons, a rollback and a hook. Suppressions would have made all six disappear, and all six would still be there.

Read nextThe 2FA that was not, and deleting 555 lines