skip to content

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%

answer

  1. types follow paths, not braces
  2. the guarded branch never arrives
  3. nothing to merge means nothing to widen
  4. two paths meeting take the union

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.

solid answer

~50 s

Control-flow analysis works on the function's flow graph, not on its indentation. The `if` splits the flow into a true path and a false path, each carrying its own narrowing. Because the true path ends in a `throw`, it never reaches the statement after the `if`, so there is nothing to merge there — the only incoming path is the false one, where `value` is `string`. The same holds for `return`, `continue`, `break` and a call to a function declared to return `never`. If the branch had *not* terminated — say it logged a warning instead — both paths would reach the join point and the compiler would union their narrowed types back to `string | undefined`. That is the whole trick behind guard clauses: terminating early is what makes the narrowing survive the rest of the body.

code

typescript · 12 lines
typescript
function shout(value: string | undefined): string {
  if (value === undefined) throw new Error("missing");
  return value.toUpperCase(); // value: string — one incoming path
}

function warnOnly(value: string | undefined): void {
  if (value === undefined) {
    console.warn("missing"); // falls through
  }
  // value: string | undefined — both paths merge here
  console.log(value?.toUpperCase());
}

go deeper

for a junior

Recognise the guard-clause pattern and be able to say that after an early return or throw the compiler already knows the value is present, so no extra check is needed below.

for a middle

Explain it in terms of paths: the guarded branch does not reach the code below, so only one path arrives and its narrowing applies; a branch that falls through creates a join whose types are unioned.

for a senior

Use the mechanism deliberately — restructure nested conditionals into terminating guards so narrowing covers the whole body, and know that a terminating helper needs an explicit never return type to count.

for a principal

Frame it as a codebase convention worth enforcing: guard-clause shape keeps the checker's knowledge alive across long functions and reduces the redundant re-validation that accumulates in deeply nested code.

## The flow graph, not the block structure TypeScript does not decide a variable's type by looking at which braces you are inside. It builds a **control-flow graph** for the function — nodes for statements, edges for the ways execution can move between them — and computes, for each reference to a variable, the type implied by every path that can reach that reference. That single idea explains guard clauses: ```ts function shout(value: string | undefined): string { if (value === undefined) throw new Error("missing"); return value.toUpperCase(); // value: string } ``` The `if` creates two edges. On the true edge, `value` is `undefined`; on the false edge, it is `string`. The true edge leads into a `throw`, which has no outgoing edge — execution leaves the function. So the `return` statement has exactly one predecessor, the false edge, and the type there is `string`. No `else` is needed because there is no second path to merge with. ## Which statements end a path Anything that makes the branch unable to fall through works the same way: ```ts function f(value: string | undefined) { if (value === undefined) return; // early return value.length; // string } for (const value of values) { if (value === undefined) continue; // skip this iteration value.length; // string } while (true) { const next = queue.pop(); if (next === undefined) break; // leaves the loop next.length; // string } ``` A call to a function whose declared return type is `never` also terminates the path, but only under specific conditions the compiler requires — the call must be to an entity with an explicit `never` return type, reached through a name it can rely on. ## What happens when the branch does *not* terminate Change the `throw` to something that falls through and the narrowing evaporates: ```ts function shout(value: string | undefined) { if (value === undefined) { console.warn("missing"); // falls through! } // value: string | undefined — two paths meet here // value.toUpperCase(); // Error: 'value' is possibly 'undefined'. } ``` At a **join point** — anywhere two or more edges converge — the compiler takes the *union* of the narrowed types arriving on each edge. `undefined` from the true path, `string` from the false path: the union is back to the declared type. This is not the compiler being pessimistic; both situations are genuinely reachable there. ## Joins also merge assignments The merge rule applies to narrowing produced by assignment, not just by checks: ```ts function render(input: string | number) { let label: string | number; if (typeof input === "string") { label = input.trim(); // label narrowed to string here } else { label = input * 2; // label narrowed to number here } // label: string | number — the union of the two incoming narrowings } ``` And when both branches assign the *same* type, the union collapses to that one type, which is why in-place normalisation works: ```ts function normalise(input: string | string[]): string[] { if (typeof input === "string") input = input.split(","); return input; // string[] — both paths carry string[] } ``` ## Why guard clauses are the idiomatic shape Guard-clause style — validate and leave, then write the happy path unindented — is often argued for on readability grounds. In TypeScript it also has a mechanical payoff: everything after the guard is a single path, so the narrowing applies to the entire remaining body instead of only to the inside of an `else` block. Nesting the happy path in an `else` gives the same types but confines them to that block and pushes the real work one level deeper each time. ```ts // same types, more nesting function shout(value: string | undefined): string { if (value !== undefined) { return value.toUpperCase(); } throw new Error("missing"); } ``` ## The limits to remember - **The analysis is per-path, not per-variable.** A narrowing is a fact about a program point; it never becomes a permanent property of the variable. - **Reachability is what matters.** A branch that terminates removes itself from every later join. A branch that logs and continues does not. - **None of it survives to runtime.** The emitted JavaScript keeps the `if` and the `throw` — those are real statements — but the type reasoning is erased; the compiler proved something for you, it did not add a check.

  • What type does a variable have after an if/else where each branch assigns a different member of its declared union?
    The union of the two narrowed types arriving at the join. Assign a `string` in one branch and a `number` in the other and the code after the `if` sees `string | number`. If both branches assign the same type, the union collapses to it, which is why normalising a value in every branch leaves the code below with one concrete type and no cast.
  • Does a call to a helper that always throws narrow the code after it?
    Only if the helper's return type is explicitly `never` and it is called through a reference the compiler can rely on. A function that merely happens to throw on every path still infers `void` when annotated, or is not treated as terminating, so the flow continues and the narrowing survives. Declaring the `never` return type is what makes it a flow terminator.
  • Is there any type-level difference between a guard clause and wrapping the happy path in an else?
    No difference in the types themselves — both give the same narrowing on their respective paths. The difference is scope and shape: after a guard clause the narrowing covers the whole remaining body at one indentation level, whereas the `else` form confines it to a block and nests deeper with each additional check.

saying these in an interview costs you the question

  • Thinks narrowing needs an explicit else branch
  • Believes the check narrows the variable permanently
  • Says logging in the branch narrows the same as throwing
  • Assumes the compiler runs the guard at runtime to decide types
  • Expects a helper that throws to narrow without a never return type

context