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.
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?