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 pageshowhide
explore
- Built-in Type Guards14 questions
- typeof, instanceof, and in Narrowing5 questions
- Truthiness and Equality Narrowing5 questions
- Aliased Conditions and Narrowing Limits4 questions
- Discriminated Unions13 questions
- Designing the Discriminant4 questions
- Switch Exhaustiveness with never4 questions
- assertNever and Exhaustiveness Helpers5 questions
- User-Defined Guards12 questions
- Type Predicates (x is T)4 questions
- Assertion Functions (asserts x is T)4 questions
- Guard Soundness and Validation Risks4 questions
- Control-Flow Analysis13 questions
- Assignment and Declared-Type Narrowing5 questions
- Narrowing Lost Across Closures4 questions
- Non-Null Assertion and Definite Assignment4 questions
questions
page 2 of 2In 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?
basics
~20 sAnnotate 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.
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; ```
basics
~10 sInference 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.
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?
basics
~20 sAn 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.
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?
basics
~20 sOn 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[].
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?
basics
~20 sNarrowing 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.
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?
basics
~20 sStop 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.
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?
basics
~20 sTypeScript'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.
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?
basics
~20 sControl-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.
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?
basics
~20 sNo 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.
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?
basics
~20 sgetElementById 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.
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?
basics
~20 sTypes 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.
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?
basics
~20 sYes, 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.
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?
basics
~20 sDeclare 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.
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?
basics
~20 sNothing 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.
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?
basics
~20 sPredicate 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.
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?
basics
~20 sJudge 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.
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;`?
basics
~20 sA 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.
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?
basics
~20 sA 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.
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?
basics
~20 sRely 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.
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?
basics
~20 sTreat 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.
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?
basics
~20 sPick 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.
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?
basics
~20 sDecide 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.
showing 31–52 of 52