Production

Five bugs our types didn't catch

TypeFirst · AgileLabs Engineering · 10 min read

We spend most of this blog telling you what types catch. This post is the other half of the ledger. A production TypeFirst codebase — no any, no as, decoders on every boundary, Result instead of throw — went through a structured adversarial review. The type discipline held: no null-pointer crashes, no undecoded data, no invisible exceptions. And the review still found real bugs.

Every one of them lives in the same place: the gap between what a type checks and what the code means. Each is worth knowing, because each one has a pattern that would have caught it. (Examples below are distilled and renamed; the shapes are real.)

1. The exhaustive switch that always returned false

A classifier over a large union of alert codes — dozens of variants, exhaustively switched, compiler satisfied:

function isResolvedAlert(alert: Alert): boolean {
  switch (alert.code) {
    case "A01": return false
    case "A02": return false
    // …seventy more cases…
    case "T15": return false  // ← should be true
    case "S14": return false  // ← should be true
  }
}

Every branch returns a boolean, so tsc is perfectly happy with a function that returns false for all eighty codes — including the handful that were supposed to return true. The alerts it was meant to surface were silently filtered out of a list view for months.

The pattern: exhaustiveness checks that you handled every case — it says nothing about handling them correctly. When a function collapses a big union down to a boolean, the type has cardinality 2 and your mistake space is enormous. Two defenses: derive the answer from data on the variant rather than re-listing codes (make the variant carry resolution: "GREEN" | "RED" and the function becomes unmistakable), and for any list-of-codes semantics, one table-driven unit test. Types don't replace the five-line test; they tell you which five lines matter.

2. The spread that wrote to a field that doesn't exist

The state field is submitSRD. Eight different action files wrote failures to submitRD — a field that exists on no type anywhere. It compiled:

type ModalState = { submitSRD: ConfirmedRemoteData<Err, Ok> }

// direct assignment — compile error, as expected:
const a: ModalState = { submitRD: failure(e) }   // ✗ rejected

// via spread — compiles without a whisper:
const b: ModalState = { ...state, submitRD: failure(e) }  // ✓ !!

TypeScript's excess-property check only fires on object literals assigned directly to a typed target. Spread the old state first and the result is inferred as ModalState & { submitRD: … } — a perfectly valid subtype. The consequence in production: the view spun on submitSRD (still Loading forever), while the failure sat in a phantom field nothing read. An infinite spinner with no error, on exactly the workflows where users most needed the error.

The pattern: spread-update is the one place structural typing works against you, and it's the default update idiom in immutable codebases. Defenses: typed update helpers per feature (setSubmit(state, srd) — a rename breaks every caller), or annotate the update itself: { ...state, submitRD: x } satisfies ModalState fails the build the way you'd hope. We now treat any raw spread that adds a key as a review smell.

3. The smart constructor that trusted itself

An opaque percentage type with a validating public constructor — and a private shortcut for values the module "knew" were already valid:

function createPercent(n: number): Maybe<Percent> {
  return n >= 0 && n <= 100 ? _createUnsafe(n) : null
}

// internal path: normalize, clamp… then skip the check
const normalized = clamp(0, 100, stripDecimals(n))
return _createUnsafe(normalized)

stripDecimals returned NaN for some inputs. clamp(0, 100, NaN) is NaN — every comparison with NaN is false, so nothing clamps it. Result: a "validated" opaque Percent wrapping NaN, flowing through code that rightly trusted the brand.

The pattern: an opaque type is a proof, and _createUnsafe is a proof you asserted instead of checked. The whole point of the smart-constructor discipline is that the check is cheap and centralized — so let every path pay it. If an internal fast path must exist, it needs the guard inside: Number.isFinite(n) before wrapping. NaN is the untyped value that lives inside number; treat every arithmetic boundary as suspect, exactly like a network boundary.

4. The decoder that was too strict

A nested field on a streamed event was optional in the domain — the upstream system sometimes has no reference number — but the decoder said required, non-empty:

type ExternalRefInfo = { referenceNumber?: Text20 }

const externalRefInfoDecoder = JD.object({
  referenceNumber: text20Decoder,  // required + rejects "" — too strict
})

The first real event without a reference number failed to decode. Not just that field — the failure bubbled to the top of the event decoder, so the entire payload was rejected, and a live view went blank on exactly the rare, important event it existed to display.

The pattern: two separate lessons. First, a decoder is a claim about the world, and an over-strict claim fails in production rather than in review — when a field is optional in reality, the decoder must say so, and your fixtures must include the sparse case, not just the happy one. Second, decode failure should be scoped to what failed: one bad item in a composite payload shouldn't nuke the whole stream. For collections, decode per-item and quarantine failures into a logged "undecodable" bucket rather than failing the batch. Strictness at the boundary is a virtue; blast radius is a choice.

5. The base64 that wasn't base64url

A hand-rolled JWT payload parser decoded with a standard base64 alphabet. JWTs use base64url- and _ instead of + and /. The current token payload happened to never produce those characters, so everything worked… latently. Any future change to the payload could start producing tokens that fail to parse, in the field, for a subset of users, unreproducibly.

The pattern: the types were fine — the specification was wrong, and no type checker knows RFC 7515. Two defenses: don't hand-roll parsing of specified formats when a vetted implementation exists; and when you must, test against samples generated by a different implementation, which is precisely the test that catches an alphabet mismatch.

The honest scoreboard

Notice what's not on this list: no undefined is not a function, no mystery exception from six layers down, no API response with a missing field reaching business logic, no impossible UI state. The type discipline eliminated whole categories — which is exactly why what remains clusters in the gaps:

TypeFirst was never "types instead of tests" or "types instead of review". It's types so that your tests and reviews can spend their entire budget on meaning — because shape is already handled. The five bugs above were all found by review and fixed in hours, in a codebase where the types guaranteed the fix couldn't break the plumbing around it. That's the actual promise: not zero bugs — shallower ones, found faster, fixed with confidence.

Want the categories types do eliminate? Start with impossible states and never throw — or read how this same codebase runs 62,000 lines with zero escape hatches.

← All posts