Programming

Notes from turning on TypeScript strict mode midway through a project

PRG

TypeScript adoption on our teams has been climbing steadily for a couple of years, but most of our existing projects were left in loose mode, `strict: false` in the tsconfig, to ease the initial migration off plain JavaScript. This quarter we finally flipped `strictNullChecks` and `noImplicitAny` on a mid-sized internal tool that had been living in that half-migrated state for over a year.

Sorting the errors into real bugs and noise

The compiler surfaced just over 400 errors the moment we flipped the flags, which was daunting enough that we spent the first day just triaging rather than fixing. About a third of the resulting errors turned out to be genuine latent bugs, mostly around optional API fields that the code had silently assumed would always be present. One particularly notable case was a billing calculation that assumed a `discount` field on an order object always existed, when in practice it was `undefined` for any order placed before a certain date; the loose-mode code had been silently treating that as `NaN` propagating through a total, and nobody had noticed because the resulting bug only affected a small slice of historical orders that rarely got revisited.

The rest were noise: defensive null checks the compiler now demands even in places where a runtime guarantee already existed, for example a value guaranteed non-null by a schema validation step earlier in the request lifecycle that TypeScript has no way to know about statically. We resolved most of these with targeted non-null assertions (`value!`) rather than restructuring otherwise-working code, though we were deliberate about only using the assertion where we could point to the actual runtime guarantee in a code comment, so a future reader isn't left wondering whether the assertion is safe or just a lazy silence of the compiler.

How we structured the rollout

Turning the flags on globally in one commit wasn't realistic given the error count, so we used per-directory tsconfig overrides to enable strict mode incrementally, starting with the newest and most actively developed modules and working backward toward older, more stable code that changes rarely. This let us ship the migration over about three weeks of normal sprint work rather than blocking other feature development on a single large pull request, and it meant the riskiest, most-changed code got the safety net first.

  • Roughly a third of the errors surfaced by strict mode were genuine latent bugs, concentrated around optional fields the code had silently assumed would always be present.
  • The remaining errors were defensive noise, largely resolved with targeted non-null assertions rather than structural rewrites, each with a comment pointing at the actual runtime guarantee.
  • Per-directory tsconfig overrides let us roll strict mode out incrementally over three weeks instead of blocking other work on one large pull request.
  • Code review discipline mattered more than tooling here; every non-null assertion added during the migration went through review specifically for whether the assumption behind it actually holds.

What we'd tell a team about to do this

Strict mode is worth turning on for any project expected to live more than a few months, and the earlier in a project's life you do it, the cheaper the migration gets, since the error count only grows as more loosely-typed code accumulates. Budget real time for the migration rather than trying to squeeze it into a sprint's slack time; ours took roughly three weeks of a single engineer's part-time attention, spread across a codebase that had been actively developed for about eighteen months.

Expect the ratio of real bugs to noise to depend heavily on how disciplined the original code was about handling optional data before strict mode ever entered the picture. A codebase that already used runtime validation libraries at its API boundaries will surface far more noise than genuine bugs, since the type system is only catching up to guarantees that already existed at runtime; a codebase without that discipline is where strict mode earns its keep, and where we'd recommend prioritizing the migration first.

Other strictness flags worth turning on at the same time

We didn't stop at `strictNullChecks` and `noImplicitAny`. `noUncheckedIndexedAccess`, which types an array or object index access as possibly `undefined` rather than assuming it always succeeds, surfaced a smaller but genuinely useful batch of errors, mostly in code that looked up a value by key from a map built at runtime and assumed the lookup would always hit. `strictFunctionTypes`, which tightens how function parameter types are checked for compatibility, caught a couple of callback signature mismatches in our event-handling code that had been silently accepted before, where a handler declared a narrower parameter type than the event emitter actually promised to pass. Neither of these flags produced anywhere near the error volume that `strictNullChecks` did, but both were cheap to enable once the bigger flags were already handled, and we'd recommend turning all of them on together rather than treating strict mode as a single binary switch.

What changed after the migration shipped

We tracked production error reports for the migrated module for the six weeks before and after the strict mode rollout, filtering specifically for errors that looked like they stemmed from an unexpected `undefined` or `null` value reaching code that assumed a value was present. That category of error dropped by roughly 70 percent in the six weeks after the migration compared to the six weeks before, which lines up with our sense that the genuine bugs the compiler caught were a real, if modest, slice of the errors users had actually been hitting in production, not just theoretical edge cases the compiler was being pedantic about. Generic type improvements were a smaller but pleasant side effect too; several utility functions written before strict mode had generic signatures that quietly defaulted to `any` in edge cases the author hadn't considered, and strict mode's stricter inference forced those signatures to actually mean what they claimed to mean.

The review process we put around non-null assertions specifically

Because non-null assertions were doing so much of the work in resolving the noisy half of the migration, we treated them as a distinct review category rather than folding them into normal code review comments. Every pull request touching the strict-mode migration got a specific pass looking only at newly added `!` assertions, checking each one against the actual runtime guarantee it was supposedly relying on, and a small number, around a dozen across the whole migration, turned out not to have a real guarantee behind them at all; they were cases where a previous engineer had simply assumed a value would be present without ever having verified it, and strict mode's null check was arguably right to be suspicious. Those got fixed properly with an actual runtime check and a graceful fallback rather than an assertion, which is exactly the category of hidden bug we were hoping strict mode would surface in the first place.

Third-party type definitions were their own category of pain

Not every error strict mode surfaced came from our own code; a handful of npm dependencies shipped type definitions that were themselves too loose to satisfy `strictNullChecks`, returning a type of `T` for a function that could genuinely return `undefined` at runtime. We couldn't fix those packages directly, so we wrapped the handful of offending calls in thin internal helper functions with corrected type signatures, verified against the library's actual documented behavior rather than its shipped types, and had the rest of the codebase call our wrapper instead of the library function directly. It's a small amount of extra indirection, but it meant the corrected types propagated everywhere the wrapper was used instead of requiring a local type assertion at every call site.

Measuring the actual time cost accurately

We tracked engineering time spent on this migration deliberately, since "how long does a strict mode migration actually take" is a question every team asks before starting and rarely gets a concrete answer to. Logging hours against a dedicated ticket rather than letting the work blend into unrelated feature tickets gave us a number we trust: just under 90 hours of a single engineer's time across the three-week window, including the triage, the fixes, and the non-null assertion review pass. That number is specific to a mid-sized codebase with roughly eighteen months of prior loose-mode history, and we'd expect it to scale with both codebase size and how long a project sat in loose mode before the migration started.

Strict mode inside our test suite specifically

Enabling strict mode for test files lagged behind enabling it for application code by about a week, and that lag was deliberate rather than an oversight. Test files tend to construct mock objects and partial fixtures that are legitimately missing fields a full production object would have, and turning on `strictNullChecks` in test files immediately surfaced a wave of complaints about incomplete mock data that had nothing to do with real application bugs. We resolved most of these with a small set of fixture-builder helper functions that return fully-typed objects with sensible defaults, letting a test override just the fields it cares about, which cut down the noise considerably compared to fixing each incomplete mock object by hand.

Deciding which modules to migrate first

We didn't strictly migrate newest-to-oldest as a hard rule; a module's error count and its recent change frequency both factored into the ordering. A module with a high error count but almost no recent commits got deprioritized relative to a module with a more modest error count but active weekly development, on the theory that the actively developed code was both more likely to introduce new type-related bugs if left unguarded and more likely to be touched again soon anyway, making the marginal cost of migrating it now lower than migrating a module nobody would touch again for months. That prioritization approach meant the team felt real safety-net benefits within the first week, on the code that mattered most day to day, rather than waiting for a strict left-to-right sweep of the whole codebase to reach the parts anyone actually cared about.

← Back to the journal

Have a project in mind?
Let’s talk.

Tell us where you are and where you want to go. We'll map the fastest route between the two.

Currently accepting new clients