skip to content

Control-Flow Analysis

The compiler's model of how a variable's type changes as execution flows through branches, assignments, and returns. Interviewers reach for it when they want to know why a narrowing you 'already did' evaporated a few lines later.

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

questions

13

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%

answer

  1. mutability decides precision
  2. can this binding change later
  3. literal collapses to its base type
  4. annotate to cap what it may hold

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.

solid answer

~40 s

Inference asks whether the binding can ever hold something else. A `const` can never be reassigned, so the compiler keeps the most precise type available — the literal type `'hi'`. A `let` can be reassigned, so keeping `'hi'` would make every later assignment an error; the compiler widens the literal to its base primitive, `string`. The same rule applies to `42` vs `number` and `true` vs `boolean`, and to object literal properties, whose slots are mutable: `const config = { mode: 'fast' }` infers `{ mode: string }`. To keep precision on a mutable binding, give it a declared type: `let mode: 'fast' | 'slow' = 'fast'`. That declared type becomes the ceiling, assignments outside it are errors, and the compiler still tracks which member it currently holds.

code

typescript · 10 lines
typescript
const greeting = "hi";   // type: "hi"
let   message  = "hi";   // type: string

const config = { mode: "fast" }; // { mode: string }

let mode: "fast" | "slow" = "fast"; // annotation keeps it precise
mode = "slow";

const copied = greeting; // "hi"
let   moved  = greeting; // string — widens at the mutable slot

go deeper

for a junior

Be able to state the rule on the spot: const keeps the exact literal type, let widens it to string, number or boolean. Say plainly that an annotation is how you keep a mutable variable precise.

for a middle

Explain that widening happens wherever the value lands in a mutable slot, which is why object literal properties widen too, and why copying a const into a let loses the literal type.

for a senior

Show where this surfaces in real code — a value that is obviously valid rejected by a union-of-literals parameter — and read the compiler message as a statement about the variable's type rather than its value.

for a principal

Discuss it as an API design lever: exposing unions of literals forces callers into precise bindings, so decide deliberately whether a public surface should demand that precision or accept a widened primitive and validate.

## Two inferences from the same initializer When TypeScript infers a variable's type from its initializer, it first asks whether the binding can ever hold a different value. ```ts const greeting = "hi"; // type: "hi" let message = "hi"; // type: string ``` A `const` binding can never be reassigned, so the compiler is free to keep the most precise type it can name: the *string literal type* `"hi"`, a type whose only member is the string `hi`. A `let` binding can be reassigned. If the compiler kept `"hi"` there, every later `message = "bye"` would be an error and the variable would be unusable, so the literal is **widened** to its base primitive type, `string`. The rule is uniform across primitives: ```ts const n1 = 42; // 42 let n2 = 42; // number const b1 = true; // true let b2 = true; // boolean ``` ## Widening happens at the mutable location The literal types produced by inference are widening literal types: they collapse to the base type wherever they land in a mutable slot. That is why widening also reaches into object literals, whose properties are mutable by default: ```ts const config = { mode: "fast" }; // { mode: string } config.mode = "slow"; // allowed — the property is string ``` And why copying a `const` into a `let` loses the literal: ```ts const a = "hi"; // "hi" let b = a; // string ``` ## Why the difference is visible in real code The distinction shows up the moment a value flows into a parameter that expects a union of literals: ```ts declare function setMode(mode: "fast" | "slow"): void; const fixed = "fast"; setMode(fixed); // OK — fixed is "fast" let current = "fast"; setMode(current); // Error: Argument of type 'string' is not // assignable to parameter of type '"fast" | "slow"'. ``` The error message is the giveaway: the compiler is not complaining about the *value*, which is perfectly valid, but about the widened *type* of the variable that carries it. Nothing about the runtime differs; the two calls emit identical JavaScript. ## Keeping precision on a mutable binding The fix is to give the mutable binding a declared type instead of relying on inference: ```ts let current: "fast" | "slow" = "fast"; setMode(current); // OK current = "slow"; // OK — inside the declared type // current = "turbo"; // Error — outside the declared type ``` The annotation does two jobs at once. It caps what the variable may ever hold — the declared type is the ceiling for every future assignment — and it lets control-flow analysis track which member of the union the variable currently holds, so a `switch` or comparison on `current` still narrows. For object literals the equivalent tool is a declared type on the variable (`const config: { mode: "fast" | "slow" } = { mode: "fast" }`), or a const assertion, which is its own topic. ## Common traps - **`const` is not deep.** `const` freezes the *binding*, not the object. A `const` object's properties still widen and are still writable; only the variable cannot be re-pointed. - **An annotation on a `const` throws precision away.** `const mode: string = "fast"` gives `string`, not `"fast"` — the declared type always wins over inference. - **Widening is not a runtime behaviour.** Types are erased; `let` and `const` differ at runtime only in JavaScript's own binding and scoping rules, which are a separate subject. Everything above is purely what the checker sees. ## The mental model Inference gives you the most useful type, not the narrowest possible one, and "useful" depends on mutability. Immutable binding, precise type; mutable binding, room to move. When you want a mutable binding to *also* be precise, you must say so with an annotation — the compiler will not guess a restriction you did not write.

  • Does the same widening happen to the properties of an object assigned to a const variable?
    Yes. `const` protects the binding, not the contents. `const config = { mode: 'fast' }` infers `{ mode: string }`, because the property slot is mutable and can be reassigned later. To keep the property precise you have to say so — annotate the variable with an explicit type such as `{ mode: 'fast' | 'slow' }`, or make the property `readonly` in a declared type.
  • If I write `const mode: string = 'fast'`, what type does mode have?
    `string`. An explicit annotation always wins over inference, so the literal type is discarded before widening is even considered. This is a common accidental precision loss: people add `: string` for readability and then wonder why the value no longer satisfies a `'fast' | 'slow'` parameter. Drop the annotation, or annotate with the union you actually mean.
  • Why does the compiler widen at all instead of inferring the literal everywhere and widening on demand?
    Because a mutable binding whose type is a single literal is unusable — the first reassignment would fail, and inference that produces an immediately broken variable is worse than a slightly looser type. Widening encodes the assumption that a `let` exists in order to change. Where that assumption is wrong, an explicit declared type states the real constraint.

saying these in an interview costs you the question

  • Claims const and let infer the same type
  • Thinks const makes an object's properties immutable
  • Says the widening is a runtime difference between const and let
  • Believes annotating const with : string preserves the literal type
  • Assumes a let can never hold a literal type

context

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, 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 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

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

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

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