skip to content

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%

answer

  1. two types, not one
  2. annotation fixes what it may ever hold
  3. reads narrow, writes are checked
  4. assignment replaces the previous narrowing

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.

solid answer

~40 s

Every variable has two types at once: the **declared type**, `string | number`, which never changes, and the **current narrowed type**, which control-flow analysis recomputes at each point in the code. Assigning a value narrows the variable to that value's type, so right after the initializer `value` is `string` and `value.toUpperCase()` compiles. Writing `value = 42` is legal because `number` is a member of the declared type, and it re-narrows the variable to `number` for the code that follows — the earlier narrowing is discarded, not merged. Writing `value = true` fails: assignability is always checked against the declared type, never the current narrowed one, so the ceiling stays `string | number` no matter what the variable currently holds.

code

typescript · 14 lines
typescript
let value: string | number = "a";
value.toUpperCase(); // narrowed to string

value = 42;
value.toFixed(2);    // re-narrowed to number

value = "a";         // still allowed: number is not the ceiling

function normalise(input: string | string[]): string[] {
  if (typeof input === "string") {
    input = input.split(","); // assignment narrows to string[]
  }
  return input;               // string[] on every path
}

go deeper

for a junior

Know that the annotation lists everything the variable may ever hold, and that using it right after an assignment gives you the methods of the value you just assigned.

for a middle

Be able to name the two types explicitly — declared and narrowed — and state the asymmetry: reads use the narrowed type, writes are checked against the declared one.

for a senior

Show how assignment narrowing removes casts from real code, such as normalising a string | string[] parameter in place, and explain what type the merge point produces.

for a principal

Argue about where the ceiling should sit at all: a wide declared type buys flexibility and costs every downstream reader a re-check, so treat annotation width as an interface decision rather than a local convenience.

## Two types, not one A TypeScript variable carries two distinct types, and most confusion about narrowing comes from conflating them. - The **declared type** is what the annotation says, or what inference produced at the declaration. It is fixed for the variable's whole life and it is the type every assignment is checked against. - The **narrowed type** is what control-flow analysis believes the variable holds *at one specific point in the code*. It is always the declared type or a subset of it, and it is recomputed for every reference. ```ts let value: string | number = "a"; // declared type: string | number (forever) // narrowed type here: string value.toUpperCase(); // OK — reads use the narrowed type ``` ## Assignment is a narrowing operation An `if (typeof value === "string")` check is not the only thing that narrows. A plain assignment narrows too: after `value = expr`, the compiler narrows the variable to the type of `expr`, intersected with what the declared type permits. ```ts let value: string | number = "a"; value.toUpperCase(); // value: string value = 42; value.toFixed(2); // value: number // value.toUpperCase(); // Error: Property 'toUpperCase' does not exist on type 'number'. ``` The second assignment does not *add* `number` to what the variable might be; it **replaces** the narrowing. Narrowing is a property of a program point, not an accumulating fact about the variable. ## The declared type is the ceiling Assignability is checked against the declared type, always: ```ts let value: string | number = "a"; value = 42; // OK — number is in the declared type // value = true; // Error: Type 'boolean' is not assignable to type 'string | number'. ``` This holds in both directions, which is the part candidates miss. Narrowing to `string` does not *forbid* assigning a number later — the narrowing is not a constraint on writes. And holding a number right now does not *permit* assigning a boolean — the ceiling never moves. A useful consequence: assignment narrowing can make an awkward parameter usable in place. ```ts function normalise(input: string | string[]): string[] { if (typeof input === "string") { input = input.split(","); // narrows input to string[] here } return input; // string[] on both paths } ``` The `then` branch assigns a `string[]`, narrowing `input` to `string[]`; the implicit `else` path already had `string[]`. Where the two paths meet, the compiler unions the narrowed types — `string[] | string[]`, i.e. `string[]` — so the `return` type-checks without a cast. ## When there is no annotation With no annotation, the declared type is the *widened* type of the initializer: ```ts let mode = "fast"; // declared type: string // mode = 3; // Error: Type 'number' is not assignable to type 'string'. ``` So omitting the annotation does not give you a variable that accepts anything — it gives you a ceiling you did not choose. If the variable is meant to hold either shape, say so: `let mode: string | number = "fast"`. ## Reading versus writing The cleanest way to hold the rule: - **Reads** use the narrowed type. That is why `value.toFixed(2)` compiles right after `value = 42`. - **Writes** are checked against the declared type. That is why `value = "a"` is still legal after the variable has become a number. ```ts let value: string | number = 42; value = "back to a string"; // fine — write checked against the ceiling value.toUpperCase(); // fine — read uses the new narrowing ``` ## Why this design The declared type is the contract you wrote; narrowing is an inference the compiler makes *within* that contract to spare you redundant checks. Letting an assignment move the ceiling would mean a variable's contract changes silently as code is edited elsewhere in the function, and every reader would have to simulate the whole body to know what it may hold. Fixing the ceiling keeps the annotation meaningful and localises narrowing to a single flow path. All of this is compile-time only. The emitted JavaScript contains no annotation, no check and no narrowing — a variable at runtime simply holds whatever was last assigned.

  • After `value = 42`, is the variable's type permanently number for the rest of the function?
    No. The narrowing holds only for the code the assignment flows into. A later `value = 'a'` re-narrows it to `string`, and a branch that assigns differently produces a different narrowed type on that path. Where paths merge, the compiler takes the union of the narrowed types from each incoming path. The declared type is what stays constant.
  • Why is `let mode = 'fast'` not the same as `let mode: string | number = 'fast'`?
    Without an annotation, inference sets the declared type to the widened initializer type, `string`. That becomes the ceiling, so `mode = 3` is an error even though you never wrote `string` anywhere. The annotation is how you widen the ceiling deliberately. Leaving it out is a choice, not a neutral default.
  • Does reassigning a parameter to narrow it in place have downsides?
    It type-checks, but it makes the parameter mean two things in one body, and readers must track which. Assigning to a fresh local — `const parts = typeof input === 'string' ? input.split(',') : input` — gives the same narrowing with an immutable binding whose type never changes. Some lint configurations ban parameter reassignment outright for that reason.

saying these in an interview costs you the question

  • Thinks assignment changes what the variable may hold later
  • Says narrowing to string forbids assigning a number afterwards
  • Believes the union accumulates every assigned type
  • Assumes an unannotated let accepts any type
  • Confuses the annotation with a runtime check on assignment

context