skip to content

Unions & Narrowing

How TypeScript turns a broad union into one specific member at each point in your code — the guards, discriminants, and control-flow rules that make union types usable. Interviewers lean on it because narrowing is where the static type system meets real runtime checks, and where unsound shortcuts hide.

part ofTypeScriptoverview, primer and where to startread it →
on this pageshow

explore

questions

page 1 of 2

In TypeScript, given `type Dir = "up" | "down" | "left"` and `function move(d: Dir)`, what is d's type inside `if (d === "up") { ... } else { ... }`, and what does the compiler do if you write `d === "top"`?

level: juniorimportance: must knowfreq 66%

answer

  1. each literal is its own type
  2. the else branch subtracts the match
  3. keep subtracting and you reach never
  4. a misspelled literal will not compile
  5. widening to string loses all of it

basics

~20 s

Equality against a literal narrows both branches: d is "up" inside the if and "down" | "left" in the else. Comparing d to "top" is a compile error, because the two types have no overlap and the test could never be true.

solid answer

~40 s

A union of string literals is a set of exact types, so `d === "up"` lets the checker split it precisely: inside the branch `d` is the literal type `"up"`, and in the else branch the matched member is removed, leaving `"down" | "left"`. Chain another check and you can reach a single member, and if you exhaust them the remaining type becomes `never`. Comparing against `"top"` does not silently evaluate to false the way plain JavaScript would — the compiler rejects it, reporting that the comparison looks unintentional because the types have no overlap. That is one of the practical wins of literal unions over bare `string`: typos in the comparison are caught at compile time rather than showing up as a branch that never runs.

code

typescript · 16 lines
typescript
type Dir = "up" | "down" | "left";

function label(d: Dir): string {
  if (d === "up") {
    const a: "up" = d;
    return a;
  }
  const rest: "down" | "left" = d; // matched member subtracted
  return rest;
}

declare let s: string;
if (s === "up") {
  const t: "up" = s; // a wider string narrows to the literal too
  void t;
}

go deeper

for a junior

Be able to state both branch types out loud — the compared literal inside the if, the remaining members in the else — and say why a typo'd literal fails to compile.

for a middle

Explain the subtraction mechanism, how chained checks walk a union down to a single member and then to never, and how literal widening on let bindings quietly destroys it.

for a senior

Argue when a closed literal union is the right model versus a plain string or an object shape, and what the no-overlap error buys you during refactors that rename a member.

for a principal

Own the vocabulary question across a system: which sets of values are closed unions in the shared types, how they evolve without breaking consumers, and where a stringly-typed field is the pragmatic choice.

## Literal types make equality informative A *literal type* is a type inhabited by exactly one value: `"up"` is a type whose only member is the string `"up"`. A union of them, `"up" | "down" | "left"`, is therefore a precise set, and an equality test against one of those literals tells the checker exactly which members remain possible. ```ts type Dir = "up" | "down" | "left"; function move(d: Dir) { if (d === "up") { d; // "up" } else { d; // "down" | "left" } } ``` Both halves matter. The true branch narrows to the compared literal; the false branch **subtracts** it. That subtraction is what makes chains work: ```ts function label(d: Dir): string { if (d === "up") return "North"; if (d === "down") return "South"; d; // "left" return "West"; } ``` After the two early returns the compiler knows only one member survives. Keep subtracting past the last one and the type becomes `never` — the empty type — which is the foundation the exhaustiveness patterns build on. ## Why the typo is an error In plain JavaScript, `d === "top"` is a perfectly legal expression that evaluates to `false`. TypeScript is stricter here than it is almost anywhere else: when the two operand types have no values in common, it reports that the comparison appears to be unintentional because the types have no overlap. `"top"` is not a member of `Dir`, so the branch could never run, and a branch that can never run is far more likely to be a typo than a deliberate choice. ```ts declare const d: Dir; // d === "top"; // error: the types have no overlap ``` This is a real argument for modelling small closed sets as literal unions rather than as `string`: with `d: string` the same typo compiles silently and the branch is simply dead. ## Widening, and why the annotation matters Literal types are easy to lose. A `let` binding initialised with a string infers `string`, not the literal, because it is mutable: ```ts let a = "up"; // string const b = "up"; // "up" const c = { dir: "up" }; // { dir: string } const e = { dir: "up" } as const; // { readonly dir: "up" } ``` If the value you compare has been widened to `string`, you get neither the precise narrowing nor the typo error — annotate the parameter or the variable as `Dir` to keep the literal information alive. ## Comparison against a wider type Equality narrowing also applies when the other operand is a plain `string`. Comparing `d` with a `string`-typed variable leaves `d` as `Dir`, because every member of `Dir` is a string and none can be excluded — nothing is gained, and nothing is broken. And going the other way, comparing a `string`-typed variable against a single literal narrows *that* variable to the literal inside the branch: ```ts declare let s: string; if (s === "up") { s; // "up" } ``` ## switch behaves the same way A `switch` on the same variable narrows each `case` clause exactly as the corresponding `if (d === ...)` would, and the `default` clause sees the members no case matched. Using that residue to prove you handled every member is a separate technique with its own idioms; the narrowing underneath it is the plain equality narrowing described here. ## Runtime reality None of this exists after compilation. `Dir` emits nothing, the branch types are erased, and the shipped JavaScript is an ordinary `if (d === "up")`. The value of the literal union is entirely in what the checker refuses to accept before that point — the misspelled comparison, the forgotten branch, the assignment of `"north"` to a `Dir`. ## Interview framing Answer with both branch types, not just the obvious one — candidates routinely give `"up"` for the true branch and forget that the else branch is the narrowed `"down" | "left"`. Then mention the no-overlap error, because it shows you have actually written this code rather than read about it.

  • What happens to the type if you keep subtracting members until none are left?
    It becomes `never`, the type with no values. That is the compiler telling you the branch is unreachable given the declared union, and it is the signal exhaustiveness helpers rely on. Reaching `never` unexpectedly usually means you narrowed something you did not intend to.
  • Why does `let d = "up"` not give you the literal type, and how do you keep it?
    A mutable `let` binding widens the literal to `string` on inference, since you could reassign it. Use `const`, annotate the binding or parameter as the union type, or apply `as const` to an object literal's property to preserve the exact literal type.

saying these in an interview costs you the question

  • Says the else branch is still the full union
  • Expects `d === "top"` to compile and just be false
  • Thinks literal unions exist at runtime as values
  • Assumes `let x = "up"` has the literal type "up"
  • Confuses a literal union with a string enum's members

context

open as a page

In TypeScript, given `function fmt(x: string | number)`, what type does the checker give `x` inside `if (typeof x === 'string')` and in the `else` branch, and which strings is the compiler willing to accept on the right-hand side of a `typeof` comparison?

level: juniorimportance: must knowfreq 78%

basics

~20 s

TypeScript narrows x to string inside the if branch and to number in the else branch. Only the eight strings typeof can actually return are accepted; comparing against any other string is reported as a comparison with no overlap.

open as a page

In TypeScript, `const greeting = 'hi'` is inferred as the literal type `'hi'`, but `let greeting = 'hi'` is inferred as `string`. Why do the two differ, and how do you keep a literal type on a mutable variable?

level: juniorimportance: must knowfreq 72%

basics

~20 s

TypeScript widens a literal only for mutable bindings: const greeting = 'hi' keeps the literal type 'hi', while let greeting = 'hi' widens to string since it can be reassigned. Annotate the let to keep a literal type.

open as a page

In TypeScript, what does the postfix `!` in `const label = user.name!` tell the compiler, and what does that line compile to in the emitted JavaScript?

level: juniorimportance: must knowfreq 72%

basics

~20 s

TypeScript's postfix ! is the non-null assertion operator: it strips null and undefined from that expression's type and is then erased. The emitted JavaScript contains no check, so a wrong assertion still throws at runtime.

open as a page

In TypeScript, a dispatch over a discriminated union ends with `default: throw new Error('unhandled kind')`. What does replacing that throw with a call to a helper declared `function assertNever(value: never): never` buy you?

level: juniorimportance: must knowfreq 55%

basics

~20 s

A plain throw only fires at runtime. Passing the value to a parameter typed never makes the compiler check that branch too, so an unhandled union member becomes a build error rather than a production surprise.

open as a page

In TypeScript, what must be true of a property for the compiler to treat it as a union's discriminant, so that comparing that property narrows the value to a single member?

level: juniorimportance: must knowfreq 70%

basics

~20 s

The tag must exist on every union member and be typed as a literal — a string, number, boolean or enum-member literal — with a different value per member, so an equality check selects exactly one member.

open as a page

In TypeScript, given `type Shape = { kind: 'circle'; radius: number } | { kind: 'square'; side: number }`, why can you read `shape.radius` inside `case 'circle':` of `switch (shape.kind)`, but not on the same value before the switch?

level: juniorimportance: must knowfreq 66%

basics

~20 s

Comparing the literal tag narrows the value, so inside case 'circle' shape is only the circle member and radius exists. Before the switch it is still the whole union, where one member has no radius.

open as a page

In TypeScript, the function `function isString(x: unknown): x is string { return typeof x === 'number'; }` compiles with no error at all. What does the compiler actually verify about a type predicate, and what does it leave entirely to you?

level: juniorimportance: must knowfreq 70%

basics

~20 s

TypeScript checks only that the asserted type is assignable to the parameter's declared type. It never inspects the body, so a guard that returns true always narrows, and a wrong predicate is an unchecked assertion the compiler trusts.

open as a page

In TypeScript, a utility module exports `function isString(value: unknown): value is string`. What does the `value is string` return type mean, and what type does `value` have inside `if (isString(value)) { ... }`?

level: juniorimportance: must knowfreq 62%

basics

~20 s

The return type value is string makes isString a user-defined type guard: it returns an ordinary boolean at runtime, but the compiler treats a true result as proof and narrows value to string inside the if-branch.

open as a page

In TypeScript 5.5 or later, `if (isDefined(v)) { v.trim(); }` narrows `v` when the helper is written `function isDefined(x: string | undefined) { return x !== undefined; }`, but reports that `v` may be undefined once you annotate that helper's return type as `: boolean`. What is going on?

level: middleimportance: must knowfreq 55%

basics

~20 s

A boolean-returning helper narrows nothing by itself. TypeScript 5.5 infers a type predicate for a helper that has no return-type annotation, exactly one return statement, an unmutated parameter, and a body whose result refines that parameter. Annotating : boolean suppresses the inference.

open as a page

In TypeScript, given `function f(x: string | number | undefined)`, what type does x have in each branch of `if (x) { ... } else { ... }`, and why does the compiler not complain that 0 and the empty string end up in the else branch?

level: middleimportance: must knowfreq 72%

basics

~20 s

Truthiness narrowing removes only constituents that can never be truthy, so the then-branch is string | number and the else-branch stays string | number | undefined. Since 0 and "" are ordinary values of number and string, the checker sees nothing wrong.

open as a page

In TypeScript with strictNullChecks enabled, how do `if (x === null)`, `if (x === undefined)` and `if (x == null)` differ in what they narrow out of a value declared `string | null | undefined`?

level: middleimportance: must knowfreq 60%

basics

~20 s

Strict comparisons remove one constituent each: after x === null returns false the type is string | undefined, and after x === undefined it is string | null. The loose form x == null is special-cased to cover both, so its false branch is plain string.

open as a page

In TypeScript, how does `if ('radius' in shape)` narrow a union of object types, and why can an optional property leave the member you expected to be removed sitting in the `else` branch?

level: middleimportance: must knowfreq 58%

basics

~20 s

The in operator keeps union members that declare the named property and drops those that do not. When the property is optional, the member can legitimately lack it at runtime, so the checker keeps that member in both branches.

open as a page

In a TypeScript project with `strictNullChecks` on, a value is declared `string[] | string | null`. Which branch of `if (typeof value === 'object')` does the checker put `null` in, what is the narrowed type there, and how do you exclude it?

level: middleimportance: must knowfreq 62%

basics

~20 s

Null lands in the true branch: the checker narrows to string[] | null, because the runtime check it models reports "object" for null. Combine the guard with an explicit non-null check, or test for the primitive member instead.

open as a page

In TypeScript, given `let value: string | number = 'a';`, what type does the compiler see for `value` on the next line, what happens if you later write `value = 42`, and what happens if you write `value = true`?

level: middleimportance: must knowfreq 66%

basics

~20 s

On the next line value is narrowed to string by the assignment. Assigning 42 is allowed and re-narrows it to number, because number is inside the declared union. Assigning true is an error: the declared type is the ceiling.

open as a page

In TypeScript, a module keeps `let socket: WebSocket | null = null` and a connect() function that reassigns it. Inside send(), the line `if (socket !== null) { queueMicrotask(() => socket.send(data)); }` is rejected because socket is possibly null, yet writing `socket.send(data)` directly after the same check is accepted. Why is the narrowing discarded inside the arrow function, and what is the standard fix?

level: middleimportance: must knowfreq 68%

basics

~20 s

Control-flow analysis stops at a function boundary for a binding that is reassignable: the checker cannot prove when the callback runs, so inside it the variable reverts to its declared type. Copy the narrowed value into a const and close over that.

open as a page

In the TypeScript helper `function assertNever(value: never): never { throw new Error('Unexpected: ' + JSON.stringify(value)); }`, why is the parameter typed `never`, and why is the return type annotated `never` as well?

level: middleimportance: must knowfreq 58%

basics

~20 s

The never parameter accepts only a value the checker proved impossible, which is what turns a forgotten case into a compile error. The never return type is assignable to any declared return type and tells control-flow analysis the call terminates.

open as a page

In TypeScript, when a `switch` over a discriminated union has a `case` for every member's tag, what type does the switched value have in the `default` branch, and how does that turn adding a new union member into a compile error?

level: middleimportance: must knowfreq 72%

basics

~20 s

It has type never: each case removed one member and nothing is left. Since only never is assignable to never, a default branch that feeds the value somewhere requiring never stops compiling as soon as an unhandled member survives.

open as a page

In TypeScript, how does a user-defined guard declared `asserts value is User` differ from one declared `value is User`, and when would you reach for each?

level: middleimportance: must knowfreq 55%

basics

~20 s

A value is User guard returns a boolean and narrows only where that boolean is tested; an asserts value is User guard returns nothing, throws on failure, and narrows the value for all code after the call.

open as a page

A service validates API responses with `function isOrder(x: unknown): x is Order { return typeof x === 'object' && x !== null && 'id' in x; }`, where `Order` is `{ id: string; items: Item[]; total: number }`. Which bad payloads still pass this guard, and how deep does a trustworthy check have to go?

level: middleimportance: must knowfreq 58%

basics

~20 s

The guard only proves the value is a non-null object with some property named id. Arrays pass, id may be a number, and items and total are never examined — so a trustworthy check must test every declared field, recursively, and array elements one by one.

open as a page

In TypeScript, a helper is written as `function isString(value: string | number): boolean { return typeof value === 'string'; }`. Callers then do `if (isString(x)) { x.toUpperCase(); }` and the compiler rejects `x.toUpperCase()`. Why does the boolean return type not narrow, and what change fixes it?

level: middleimportance: must knowfreq 70%

basics

~20 s

A boolean return type says nothing about which argument was checked, and the checker never looks inside a called function. Declaring the return type as value is string turns the helper into a type predicate, so callers narrow.

open as a page

You are handed the TypeScript type `{ isLoading: boolean; data?: User[]; error?: Error }` used to represent the state of an async request, and asked to make illegal states unrepresentable. How would you redesign it, and what does the change buy the call sites?

level: seniorimportance: must knowfreq 62%

basics

~20 s

Replace the flag bag with a union tagged by a status literal — idle, loading, success carrying required data, error carrying required error. Combinations like loading-with-an-error stop being constructible, and call sites lose their optional-field guesswork.

open as a page

In TypeScript, a function takes `options: { onSave?: () => void }` and writes `if (options.onSave) { setTimeout(() => options.onSave(), 0); }`. The compiler reports that `options.onSave` is possibly undefined inside the callback even though the check is on the line above. Why does the property check not carry into the callback, and what one-line change fixes it without an assertion?

level: juniorimportance: should knowfreq 52%

basics

~20 s

A property lives on a mutable object, so the checker will not assume it still holds its checked value whenever the callback later runs. Read it into a local const first, check that const, and call the const inside the callback.

open as a page

In TypeScript, a helper is declared `function assertIsString(value: unknown): asserts value is string`. What does that return type tell the compiler, and what must the body do when value is not a string?

level: juniorimportance: should knowfreq 35%

basics

~10 s

It declares an assertion function: returning normally is the compiler's proof, so from the call onward TypeScript treats value as a string. The body must throw when value is not a string.

open as a page

In TypeScript, if you write `const isString = typeof value === "string";` and then `if (isString) { ... }`, does the compiler still narrow `value` inside the block, and what does it require for that to work?

level: middleimportance: should knowfreq 45%

basics

~20 s

Yes. Since TypeScript 4.4 the checker follows a condition stored in a const and re-applies it wherever that const is used, provided the alias is a const without a type annotation and the value it tests is itself effectively constant.

open as a page

In TypeScript, `function f(x: string | number, y: string | boolean)` contains `if (x === y) { ... }`. What are x and y inside that branch, and what would happen if their types shared nothing at all?

level: middleimportance: should knowfreq 42%

basics

~20 s

Both operands narrow to what they could share: inside the branch x and y are each string. If the two declared types had no values in common, the comparison would not compile — the checker rejects it as unintentional because the types have no overlap.

open as a page

In TypeScript, what type does `if (Array.isArray(x))` narrow `x: unknown` to, and why is that a hole in an otherwise strict codebase?

level: middleimportance: should knowfreq 38%

basics

~20 s

It narrows to any[], because the standard library declares the check with the predicate arg is any[]. Every element read out of it is any, so a strict codebase silently loses type safety inside the branch.

open as a page

A TypeScript function takes `value: string | undefined` and starts with `if (value === undefined) throw new Error('missing');`. Why does the rest of the body see `value` as `string` even though there is no else branch?

level: middleimportance: should knowfreq 56%

basics

~20 s

Because the guarded branch never falls through. TypeScript analyses the function as a flow graph, and the only path reaching the code after the if is the one where the check was false, so that path carries the narrowing without needing an else.

open as a page

In TypeScript, what does the `!` assert in the declarations `let started!: boolean;` and `private db!: DbClient;`, and how is that different from the `!` in `db!.query(sql)`?

level: middleimportance: should knowfreq 52%

basics

~20 s

A declaration-site ! is a definite-assignment assertion: it promises the variable or class field is assigned before it is read, silencing strictPropertyInitialization and used-before-assigned errors. Postfix ! in an expression is a different operator that drops null and undefined at one use site.

open as a page

A TypeScript codebase dispatches through a lookup object typed `Record<Shape['kind'], (s: Shape) => string>` instead of a switch with an assertNever fallback. How does that enforce exhaustiveness, and where does the compiler report a missing case?

level: middleimportance: should knowfreq 34%

basics

~20 s

Record over the tag union makes one required property per tag, so a missing handler is an error at the object literal that builds the table — reported at declaration, whether or not the table is ever called, and with the missing key named.

open as a page

showing 1–30 of 52