DEV Community

Cover image for Postmortem: How a Minor Version Bump Silently Collapsed Our Types to `never`
Marvin Okafor
Marvin Okafor

Posted on

Postmortem: How a Minor Version Bump Silently Collapsed Our Types to `never`

Some bugs announce themselves with a stack trace. Others just make your code quietly stop meaning anything. This is about the second kind — a dependency resolution issue in a TypeScript monorepo that didn't throw, didn't fail CI in an obvious way, and took real digging to root-cause, because the symptom looked nothing like the cause.

The symptom

We're a Next.js + TypeScript monorepo using npm workspaces, on top of Supabase/Postgres with row-level-security policies. One afternoon, a teammate opened a PR, and the type checker flagged what looked like an unrelated function as having a parameter of type never. Not unknown, not anynever. That's the type TypeScript uses to say "this code path is unreachable" or "no value can satisfy this type."

The function in question was very much reachable. It was called constantly, in production, successfully. TypeScript was simply wrong about it — or rather, TypeScript was being correctly told the wrong thing.

Why never is the worst possible symptom

never is a black hole in the type system. Once a type collapses to never, everything downstream that touches it also tends to become uninferrable or nonsensical, because there's no value that can inhabit never. This means the first place you see an error is rarely anywhere near the actual cause — the type has already been wrong for several hops by the time the checker has no choice but to complain.

That property makes this class of bug genuinely dangerous to debug by intuition. Staring at the flagged function tells you almost nothing, because the function is innocent. You have to work backwards through the type's provenance instead of forwards from the symptom.

Tracing it back

The investigation went roughly like this:

  1. Confirm it's not a logic bug. Runtime behaviour was correct. This ruled out anything in our own function bodies and pointed at the type layer specifically — either our type definitions or something upstream of them.
  2. Bisect the type, not the code. I traced the offending type backwards through each intermediate type alias and generic constraint until I found the layer where it stopped being sane. It turned out to originate from a shared type re-exported from one of our internal workspace packages.
  3. Check what changed. git log on the lockfile, not the source, is the move here — the source hadn't changed. A transitive dependency had bumped a minor version, which under semver should have been safe.
  4. Reproduce in isolation. I pinned every workspace package to the exact versions from before the bump, confirmed the type error disappeared, then bumped dependencies one at a time until it reappeared. This isolated the exact package and version.
  5. Understand the actual mechanism. The new minor version had changed an internal conditional type in a way that was backwards-compatible at the value level but not at the type inference level for one specific generic usage pattern we relied on. Semver protects you from breaking runtime behaviour; it says nothing about breaking type inference for edge-case generic usage. That's the real lesson here.

The fix, and the more important fix

The immediate fix was a version pin with a comment explaining exactly why, linking to the isolated repro. That's necessary but not sufficient — a pin with no explanation just becomes a mystery for the next person, and eventually someone "cleans it up" and reintroduces the bug.

The more important fix was writing it up: what the symptom looked like, why it was misleading, the bisection method that found it, and the underlying mechanism (type-level semver violations in transitive dependencies). That doc is what turns a three-hour debugging session into a five-minute fix the next time a teammate hits something that smells similar.

What I'd generalise from this

  • When a type error implicates innocent code, suspect provenance, not logic. never and its cousins are usually downstream symptoms of an upstream problem.
  • Bisect the dependency graph, not just your own commits. Runtime-correct, type-broken changes are invisible to normal "did my code change" instincts.
  • Semver is a promise about behaviour, not about type inference. Especially in libraries that lean on conditional or mapped types, a "minor" bump can break inference for edge-case usages that the maintainers never tested against.
  • The write-up is part of the fix. A pinned version with no documented reasoning is technical debt with a delay timer on it.

Debugging this kind of thing is oddly satisfying once you see the shape of it — the bug isn't in the obvious place; it's in the second-order effects of a decision made three layers away. If you've hit something similar in a large TypeScript monorepo, I'd be curious to compare notes on tooling for catching type-level regressions from dependency bumps before they reach a PR.

Top comments (0)