skip to content

A TypeScript service paginates with `if (page.cursor)` where `cursor: string` and the API sends `""` on the last page, and a dashboard hides a counter with `if (count)` where `count: number` can legitimately be 0. Beyond patching the two call sites, how do you use the type layer so this class of bug cannot keep recurring?

level: seniorimportance: should knowfreq 38%

answer

  1. the checker removes types, not values
  2. absence needs its own representation
  3. normalise the sentinel at the edge
  4. states as shapes, not as falsiness
  5. strictNullChecks or none of it bites

basics

~20 s

Stop encoding absence as a falsy in-band value. Model the missing state as null or an optional property so the checker can see it, normalise the API's "" at the boundary, and require presence checks like x != null instead of truthiness, which narrows types and never values.

solid answer

~50 s

The root cause is that truthiness narrowing can only remove union constituents that are *always* falsy, so an in-band falsy value like `""` or `0` is invisible to the compiler — `if (page.cursor)` is type-correct and always will be. Patching call sites treats the symptom. The durable fix is representational: give absence exactly one representation the type system can see, such as `cursor: string | null`, and normalise the API's empty string to `null` in the adapter that parses the response, so the falsy sentinel never reaches domain code. Then presence checks become `page.cursor !== null` or `x != null`, which narrow precisely and leave legitimate zeros and empty strings alone. Where a value really has two distinct states, model it as a union of two shapes carrying a tag, so "is there more?" is answered by a field the checker forces you to handle rather than by coincidence of falsiness. `strictNullChecks` has to be on for any of this to bite.

code

typescript · 11 lines
typescript
type Item = { id: string };
type RawPage = { items: Item[]; cursor: string }; // "" means last page
type Page = { items: Item[]; cursor: string | null };

function toPage(raw: RawPage): Page {
  return { items: raw.items, cursor: raw.cursor === "" ? null : raw.cursor };
}

function hasMore(page: Page): boolean {
  return page.cursor !== null; // "" can no longer end the loop by accident
}

go deeper

for a junior

Recognise that 0 and the empty string are falsy, and that checking presence needs !== undefined or != null rather than a bare if.

for a middle

Explain why the compiler cannot flag the bug — truthiness narrowing removes always-falsy constituents only — and show the type change that makes absence visible.

for a senior

Demonstrate the durable fix: one representation for absence, normalised in the adapter, guard style aligned with it, plus the one review rule you would enforce and where you would leave truthiness alone.

for a principal

Own the contract stance across services — whether wire formats may use falsy sentinels at all, who owns normalisation, and what migrating existing consumers to a nullable or tagged shape costs.

## Why the compiler cannot save you here Truthiness narrowing removes union constituents, never values. In the true branch of `if (x)` the checker drops types that can never be truthy — `undefined`, `null`, `false`, `0`, `""` as *literal types*. In the false branch it drops types that can never be falsy — object, array and function types. `string` and `number` fall into neither category, because `""` and `0` are ordinary members of them. So `if (page.cursor)` where `cursor: string` is a perfectly well-typed question. It simply is not the question the author meant, and no compiler flag exists that will spot the difference, because the intent lives in your head. That is the sentence to lead with in an interview: this is not a checking gap to be closed, it is a **modelling** mistake to be designed out. ## Rule one: absence gets exactly one representation Pick one nullish value — most codebases choose `null` for "explicitly nothing" and let `undefined` mean "property absent" — and never let a falsy in-band value stand in for it. ```ts // before: "" secretly means "no more pages" type Page = { items: Item[]; cursor: string }; // after: the absent state has a type of its own type Page = { items: Item[]; cursor: string | null }; ``` Once `null` is a constituent, `page.cursor !== null` narrows exactly the thing you care about, and a `""` that somehow survives is treated as a real cursor rather than silently ending the loop — a visible bug instead of a silent one. ## Rule two: normalise at the boundary, once The wire format is not yours to change, so convert it where the data enters: ```ts type RawPage = { items: Item[]; cursor: string }; function toPage(raw: RawPage): Page { return { items: raw.items, cursor: raw.cursor === "" ? null : raw.cursor }; } ``` One function owns the sentinel; everything downstream sees the honest type. This is the same discipline as parsing dates or trimming strings at the edge — the domain model should not carry the transport's compromises. Do the conversion explicitly (`=== ""`) rather than with a truthiness shortcut, or you have simply moved the bug into the adapter. ## Rule three: make the two states two shapes when it matters When a result genuinely has distinct states, encode the state rather than inferring it from a value: ```ts type PageResult = | { done: true; items: Item[] } | { done: false; items: Item[]; cursor: string }; ``` Now "is there more?" is a field the checker makes you read, and `cursor` is only reachable in the branch where it exists — the truthiness check is not merely discouraged, it is unexpressible. The cost is a slightly heavier type and a conversion at the boundary, so reserve it for values whose two states drive real control flow. ## Rule four: the checks themselves With absence modelled, the guard style follows: - Presence: `if (x != null)` or `if (x !== undefined)` — narrows the nullish constituents and nothing else. - Defaults: `x ?? fallback`, which only substitutes for nullish values, instead of a truthiness-based fallback that eats `0` and `""`. - Emptiness: ask the real question — `items.length > 0`, `s.trim() !== ""` — rather than leaning on falsiness to mean two things at once. And keep `strictNullChecks` on. Without it `null` and `undefined` are assignable to every type and never appear as constituents, so none of the above narrows anything and the whole strategy is decorative. ## Rule five: catch the residue in review Some truthiness checks are fine — on a `boolean`, on an object reference, on a value whose falsy members you genuinely want in the same bucket. The reviewable rule is narrow: *a truthiness check on a value whose type includes `string` or `number` is a defect unless a comment says why*. That is a rule a human can apply consistently, and it is where a lint rule pointed at nullable-value truthiness earns its place if your team already runs one. ## The counter case The dashboard's `if (count)` needs no type change at all — `count: number` is already honest. The bug is that the code asked "is this truthy?" when it meant "is this present?" or "is this greater than zero?". Write `count > 0` if you want to hide empty states, or render zero, which is usually the correct product answer. Not every instance of this bug is a modelling problem; part of the senior judgment is telling the two apart instead of applying `| null` reflexively everywhere. ## Interview framing A strong answer moves through three levels: the mechanism (narrowing removes types, not values, so no flag can catch it), the model (one representation for absence, normalised at the boundary, states as shapes when they drive control flow), and the guardrail (the guard style, `strictNullChecks`, and the one review rule you would actually enforce). Answers that stop at "use `!== undefined`" have fixed the two call sites and nothing else.

  • Would enabling a stricter compiler flag have caught the cursor bug?
    No. No flag exists that distinguishes "is truthy" from "is present" on a value typed `string`, because both are legitimate questions about the same type. `strictNullChecks` is still a prerequisite — it is what makes `string | null` a real distinction — but it catches missing null handling, not a falsy in-band sentinel.
  • When is a plain truthiness check still the right thing to write?
    On a `boolean`, on an object or array reference where you only care whether it exists, and on values whose falsy members you deliberately want in the same branch. The reviewable line is: a truthiness check on a value whose type includes `string` or `number` needs a comment justifying it, otherwise treat it as a defect.

saying these in an interview costs you the question

  • Says a compiler flag can catch truthiness misuse on a string
  • Fixes only the call sites and calls it done
  • Keeps "" as the sentinel and adds a length check everywhere
  • Adds `| null` to every field reflexively
  • Believes narrowing can exclude 0 from the type number

context