← Back to question bank
DebuggingMidMedium#1018 · 25m

Debug TypeScript narrowing

Explain unknown input, discriminated unions, and exhaustive checks. Then apply it to a realistic product screen where a user action, browser behavior, and rendering timing all matter.

Answer Strategy

Narrowing bugs are the question that exposes whether you read TypeScript errors or fight them. The interview-grade rule: narrowing is local. A check inside a closure does not survive an await, a callback boundary, or a method call that re-reads the variable. The moment you reach for `as` you have papered over a real bug.

Fix narrowing with three tools, in order. (1) A switch on the discriminant that returns in every branch — TypeScript narrows perfectly inside each case. (2) An exhaustiveness check via assertNever(value: never) — adding a new tag fails the compile until you add a case. (3) A type predicate function (`value is Ready<T>`) for narrowing inside `.filter`, `.find`, or other higher-order helpers where the inline check would not survive.

Volunteer the production failures. `as any` and `as unknown as T` silence narrowing locally and ship a runtime crash. A discriminated union with optional discriminant (`tag?: 'ready'`) makes every case impossible to narrow — the discriminant must be required. A schema-validated payload from the network must be narrowed *at the boundary* (Zod, valibot) so the rest of the app sees a precise type. The reference shows the canonical fix: switch + assertNever for the closed union, plus a type predicate for higher-order use.

Reference Implementation: Broken Vs Fixed Discriminated-Union Narrowing

brokenSummary shows two anti-patterns (cast through Promise, `as any`). summary fixes both with a switch + assertNever. isReady demonstrates a type predicate for filter().

// The bug: a discriminated union loses its narrowing because the code
// reads the discriminant through an intermediate variable, calls a
// function that returns 'unknown', or destructures inside a callback
// where TypeScript cannot follow the flow.

type Pending = { tag: 'pending' };
type Ready<T> = { tag: 'ready'; data: T };
type Failed = { tag: 'failed'; message: string };
type Resource<T> = Pending | Ready<T> | Failed;

// BROKEN: the alias 'state.tag' is read inside a closure. After the
// .then(...) microtask, TypeScript no longer narrows because state could
// have been reassigned. Even worse, the function body uses 'state.data'
// which only exists on Ready — TypeScript will allow this only because
// of an unsafe \`as\` cast that hides the bug.
function brokenSummary<T>(state: Resource<T>): string {
  // BUG: \`if (state.tag === 'ready')\` narrows here, but the handler below
  // re-reads the same union without narrowing because 'tag' is captured.
  return Promise.resolve(state)
    .then((current) => {
      if (current.tag === 'ready') {
        return 'Ready: ' + JSON.stringify((current as Ready<T>).data);
      }
      // The any-cast bypasses narrowing entirely.
      return 'Pending or failed: ' + (current as any).message;
    }) as unknown as string;
}

// FIXED: narrow with a switch that returns in every branch and uses an
// exhaustiveness check (\`assertNever\`) to catch missing cases at compile
// time. No casts, no \`any\`, no microtask boundary inside the narrowing.
function assertNever(value: never): never {
  throw new Error('Unhandled discriminant: ' + JSON.stringify(value));
}

export function summary<T>(state: Resource<T>): string {
  switch (state.tag) {
    case 'pending':
      return 'Loading...';
    case 'ready':
      return 'Ready: ' + JSON.stringify(state.data);
    case 'failed':
      return 'Failed: ' + state.message;
    default:
      return assertNever(state);
  }
}

// Bonus: a runtime narrowing helper for unknown JSON. Type predicates
// keep narrowing consumer-side without forcing a heavy schema library.
export function isReady<T>(value: Resource<T>): value is Ready<T> {
  return value.tag === 'ready';
}

Testing Strategy

Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.

Type-level
Compile-time: assertNever fails the build when a new tag is added without a case. Try adding `tag: 'cancelled'` to Resource and confirm the build breaks until summary handles it.
Runtime
Cover each tag with one test. Do not test the type system; test the behavior the function returns for each branch.
Boundary
Pair this with a Zod schema at the API edge. The schema validates the payload, then the rest of the app sees the discriminated union without any casts.
import { describe, it, expect } from 'vitest';

describe('summary', () => {
  it('returns the loading message for pending', () => {
    expect(summary<number>({ tag: 'pending' })).toBe('Loading...');
  });

  it('serializes ready data', () => {
    expect(summary({ tag: 'ready', data: { id: 'x' } })).toBe('Ready: {"id":"x"}');
  });

  it('reports the failure message', () => {
    expect(summary({ tag: 'failed', message: 'timeout' })).toBe('Failed: timeout');
  });
});

describe('isReady predicate', () => {
  it('narrows correctly inside a filter', () => {
    const list = [
      { tag: 'pending' as const },
      { tag: 'ready' as const, data: 1 },
      { tag: 'failed' as const, message: 'err' },
    ];
    const ready = list.filter(isReady);
    expect(ready).toHaveLength(1);
    expect(ready[0].data).toBe(1);
  });
});

Interviewer Signal

Shows whether you understand typescript narrowing as an operating model, not as memorized trivia.

Constraints

  • Use one concrete browser or React-facing example.
  • Name the failure mode a production user would notice.
  • Keep the first answer under two minutes before expanding.

Model Answer Shape

  • Start with the rule: unknown input, discriminated unions, and exhaustive checks.
  • Tie the rule to ownership: what runs in render, what runs after paint, what is external state, and what must be cleaned up.
  • Close with the smallest test, trace, or code review check that would catch the bug.

Tradeoffs

  • A short interview answer is easier to follow, but a senior answer must still name the edge case.
  • Framework vocabulary helps only after the browser or language rule is clear.

Edge Cases

  • Slow devices where timing bugs become visible.
  • Repeated user actions before async work settles.
  • Browser defaults that differ from custom component behavior.

Testing And Proof

  • Unit-test the pure decision when possible.
  • Use an interaction test for focus, keyboard, timing, or cleanup behavior.

Follow-Ups

  • How would this change in a React component?
  • What would you log or profile if this broke in production?