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 2 of 2

In TypeScript, how can a function that dispatches over a discriminated union get a compile error for a missing case without calling any assertNever helper, and which compiler settings does that depend on?

level: middleimportance: should knowfreq 40%

basics

~20 s

Annotate the function's return type explicitly and omit the fallback branch. A missing case leaves the end point reachable, and under strictNullChecks the compiler reports that the function lacks an ending return statement. noImplicitReturns gives the same protection.

open as a page

In TypeScript, this code fails to compile — why, and what are the ways to fix it? ```ts type Shape = { kind: 'circle'; r: number } | { kind: 'square'; size: number }; const c = { kind: 'circle', r: 1 }; const s: Shape = c; ```

level: middleimportance: should knowfreq 52%

basics

~10 s

Inference widens the object's kind property to string, and string is not assignable to the literal type 'circle'. Fix it by annotating the variable as Shape, adding as const, or using satisfies Shape.

open as a page

In TypeScript, calling this helper reports "Assertions require every name in the call target to be declared with an explicit type annotation": `const assertIsString = (v: unknown): asserts v is string => { if (typeof v !== "string") throw new TypeError(); };`. Why is the call rejected, and how do you fix it?

level: middleimportance: should knowfreq 42%

basics

~20 s

An assertion call only works when every name in the call target has a written-out type, and this const's type is inferred from its arrow initializer. Fix it by annotating the binding with a function type, or by using a function declaration.

open as a page

In TypeScript, given `const names: (string | undefined)[]`, what type does `names.filter(n => n !== undefined)` produce, and how do you guarantee the result is `string[]` regardless of the compiler version?

level: middleimportance: should knowfreq 55%

basics

~20 s

On TypeScript 5.5 and later the compiler infers a type predicate for that arrow and the result is string[]; on earlier versions it stays (string | undefined)[]. Passing a function declared with a value is string return type always gives string[].

open as a page

In TypeScript, `if (typeof obj[key] === "string") { obj[key].toUpperCase(); }` compiles when `key` is declared `const key = "a"`, but fails when `key` is a `let`. Why does the declaration of the key decide this, and why is the narrowing you do get still unsound?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Narrowing an element access requires the key to be a stable literal, so the checker can treat both obj[key] occurrences as the same slot. A const key has a literal type; a let key widens to string and is mutable, so the two accesses are not matched.

open as a page

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%

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.

open as a page

In TypeScript, `pet` is declared `Dog | Cat`, where both classes declare exactly the same members. Why does `if (pet instanceof Dog)` fail to narrow `pet` to `Dog`, and what change makes the narrowing work?

level: seniorimportance: should knowfreq 42%

basics

~20 s

TypeScript's type system is structural, so two classes with identical members are interchangeable types. Narrowing keeps every union member compatible with the target, which is both of them. Giving one class a private or #private field makes them incompatible and restores the narrowing.

open as a page

A TypeScript module has `let current: User | null = null;` and an exported `reset()` that sets it to null. Another function writes `if (current !== null) { reset(); console.log(current.name); }` — this compiles but throws at runtime. Why does the compiler allow it, and how would you make the code safe?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Control-flow analysis only invalidates a narrowing at assignments it can see on the path being analysed. A call to reset() is opaque to it, so the narrowing survives the call even though the value no longer holds. Copy the checked value into a local const to make the read safe.

open as a page

In TypeScript, a handler writes `if (state.pending) { await flush(); state.pending.cancel(); }`, where `pending` is an optional property and `flush()` may clear it. Does the compiler complain about the access after the await, is its answer sound, and how would you write this safely?

level: seniorimportance: should knowfreq 38%

basics

~20 s

No complaint: the checker invalidates narrowing only on assignments it can see in the same body, so calls and awaits leave it intact. That is deliberately unsound — snapshot the value into a const before awaiting, or re-check after it.

open as a page

A TypeScript app starts with `const root = document.getElementById("app")!` and, after a template change, crashes in production with "Cannot read properties of null". Why did the type checker not prevent this, and how would you restructure the code?

level: seniorimportance: should knowfreq 48%

basics

~20 s

getElementById is typed to return an element or null precisely because the element may be missing; the ! told the checker to drop the null case and emits no check of its own. Replace the assertion with an explicit test that throws a descriptive error at startup.

open as a page

A TypeScript service compiles with no errors, yet its assertNever helper throws "Unexpected value" in production. How is that possible, and how would you harden the code?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Types are erased at compile time, so the exhaustiveness proof covers only the code — not the data. A value whose tag is outside the declared union reached the dispatch, almost always through an unvalidated payload or an as assertion at a boundary.

open as a page

A TypeScript service switches over a discriminated union of message types that it built by casting the result of `JSON.parse`, and the compiler narrows the value in the `default` branch to `never`. Can that branch still execute at runtime, and what does that mean for how you write it?

level: seniorimportance: should knowfreq 46%

basics

~20 s

Yes, it can run. Types are erased, so the emitted switch has no idea the default is supposed to be impossible, and a payload with an unrecognised tag lands there. Write it defensively: log the actual tag and fail loudly.

open as a page

You want one TypeScript helper, invariant(condition, message), that throws when the condition is falsy and also narrows types for the code that follows. What signature makes that work, and what does a call like `invariant(typeof id === "string")` narrow?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Declare it with the bare assertion form: function invariant(condition: unknown, message?: string): asserts condition. After the call the compiler applies whatever narrowing the condition expression would have produced inside an if, so the id binding becomes string.

open as a page

A codebase fetches JSON with `async function getJson<T>(url: string): Promise<T> { const res = await fetch(url); return res.json(); }`. Why is this signature a type-safety hole, and what would you return instead?

level: seniorimportance: should knowfreq 52%

basics

~20 s

Nothing decides T except the caller's annotation, and the body compiles only because the DOM lib types Response.json() as Promise<any>. The signature is a disguised cast. Return Promise<unknown> and force a guard, or take a validating parser that infers T.

open as a page

A team hand-writes `interface Order` and a separate `isOrder(x: unknown): x is Order` guard. Months later a required field is added to `Order`, the guard is never updated, and nothing fails to compile. Why does nothing break, and how would you stop this class of drift?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Predicate bodies are never checked against the asserted type, so a guard silently keeps promising a shape it no longer verifies. The durable fix is one source of truth: derive the static type from a runtime schema so the check cannot fall behind the type.

open as a page

As a tech lead, how would you decide where a non-null assertion (`!`) is acceptable in a TypeScript codebase and where it must be replaced by a real check?

level: principalimportance: should knowfreq 36%

basics

~20 s

Judge each assertion by where the knowledge lives. It is acceptable when the invariant is established visibly nearby and cheap for a reviewer to confirm; it is unacceptable on data crossing a trust boundary, where validation belongs once at the edge instead.

open as a page

With `noImplicitAny` enabled, why does the TypeScript declaration `let value;` compile without an implicit-any error, and what type does `value` have after `value = 1;`?

level: middleimportance: nice to knowfreq 30%

basics

~20 s

A variable declared with no annotation and no initializer gets an evolving type rather than a fixed any: TypeScript infers it at each use from the assignments that reach that point. After value = 1, reads of value see number.

open as a page

In TypeScript, what does a method whose declared return type is `this is Foo` do at call sites, and when would you model a check that way instead of exporting a standalone `value is Foo` guard?

level: seniorimportance: nice to knowfreq 24%

basics

~20 s

A method returning this is Foo is a type predicate on the receiver: when it returns true, the object you called it on is narrowed to Foo for the rest of the branch. Reach for it when the object owns the state being checked.

open as a page

TypeScript's narrowing through const-aliased conditions, constant-key element accesses and inferred predicates can silently disappear when someone changes a `const` to a `let` or adds a second `return` to a helper. Across a large codebase, how do you decide where to rely on inferred narrowing and where to make the narrow type explicit?

level: principalimportance: nice to knowfreq 22%

basics

~20 s

Rely on inferred narrowing where the check and its use are visible together inside one function, and make the narrow type the declared type wherever data crosses a module or API boundary, so the guarantee lives in a signature rather than in a body someone can edit.

open as a page

A large TypeScript codebase keeps producing 'possibly null' and 'possibly undefined' errors when checked values are used inside callbacks and after awaits, and the team's habit is to silence each one at the call site. What conventions would you set so narrowing survives by construction, and what do those conventions cost?

level: principalimportance: nice to knowfreq 24%

basics

~20 s

Treat the errors as a signal that mutable state is read late. Narrow once at the boundary into immutable locals, pass narrowed values into callbacks as arguments, and model state as replaced discriminated unions rather than optional fields flipped in place.

open as a page

In TypeScript, you are choosing the discriminant for a union whose values are also serialized as JSON and exchanged with other services. What drives your choice of tag name and tag values, and what do you commit to by making that choice?

level: principalimportance: nice to knowfreq 28%

basics

~20 s

Pick one conventional, required field name that carries no data of its own, give it readable string-literal values that are unique within the union, and treat those values as a published contract: renaming one is a breaking change for every producer and consumer.

open as a page

Your TypeScript library exports a discriminated union of event types, and consumers switch over it exhaustively so the leftover value in `default` is `never`. Adding one member breaks every consumer's build. How do you weigh keeping that compile-time exhaustiveness against letting consumers absorb unknown members?

level: principalimportance: nice to knowfreq 27%

basics

~20 s

Decide whether the union is closed or open, and say so in the contract. A closed union makes every addition a breaking change consumers must handle; an open union asks consumers to keep a real fallback branch and gives up compile-time exhaustiveness.

open as a page

showing 31–52 of 52