In TypeScript, given `type Dir = "up" | "down" | "left"` and `function move(d: Dir)`, what is d's type inside `if (d === "up") { ... } else { ... }`, and what does the compiler do if you write `d === "top"`?
answer
- each literal is its own type
- the else branch subtracts the match
- keep subtracting and you reach never
- a misspelled literal will not compile
- widening to string loses all of it
basics
~20 sEquality against a literal narrows both branches: d is "up" inside the if and "down" | "left" in the else. Comparing d to "top" is a compile error, because the two types have no overlap and the test could never be true.
solid answer
~40 sA union of string literals is a set of exact types, so `d === "up"` lets the checker split it precisely: inside the branch `d` is the literal type `"up"`, and in the else branch the matched member is removed, leaving `"down" | "left"`. Chain another check and you can reach a single member, and if you exhaust them the remaining type becomes `never`. Comparing against `"top"` does not silently evaluate to false the way plain JavaScript would — the compiler rejects it, reporting that the comparison looks unintentional because the types have no overlap. That is one of the practical wins of literal unions over bare `string`: typos in the comparison are caught at compile time rather than showing up as a branch that never runs.
code
typescript · 16 linestype Dir = "up" | "down" | "left";
function label(d: Dir): string {
if (d === "up") {
const a: "up" = d;
return a;
}
const rest: "down" | "left" = d; // matched member subtracted
return rest;
}
declare let s: string;
if (s === "up") {
const t: "up" = s; // a wider string narrows to the literal too
void t;
}go deeper
Be able to state both branch types out loud — the compared literal inside the if, the remaining members in the else — and say why a typo'd literal fails to compile.
Explain the subtraction mechanism, how chained checks walk a union down to a single member and then to never, and how literal widening on let bindings quietly destroys it.
Argue when a closed literal union is the right model versus a plain string or an object shape, and what the no-overlap error buys you during refactors that rename a member.
Own the vocabulary question across a system: which sets of values are closed unions in the shared types, how they evolve without breaking consumers, and where a stringly-typed field is the pragmatic choice.
## Literal types make equality informative A *literal type* is a type inhabited by exactly one value: `"up"` is a type whose only member is the string `"up"`. A union of them, `"up" | "down" | "left"`, is therefore a precise set, and an equality test against one of those literals tells the checker exactly which members remain possible. ```ts type Dir = "up" | "down" | "left"; function move(d: Dir) { if (d === "up") { d; // "up" } else { d; // "down" | "left" } } ``` Both halves matter. The true branch narrows to the compared literal; the false branch **subtracts** it. That subtraction is what makes chains work: ```ts function label(d: Dir): string { if (d === "up") return "North"; if (d === "down") return "South"; d; // "left" return "West"; } ``` After the two early returns the compiler knows only one member survives. Keep subtracting past the last one and the type becomes `never` — the empty type — which is the foundation the exhaustiveness patterns build on. ## Why the typo is an error In plain JavaScript, `d === "top"` is a perfectly legal expression that evaluates to `false`. TypeScript is stricter here than it is almost anywhere else: when the two operand types have no values in common, it reports that the comparison appears to be unintentional because the types have no overlap. `"top"` is not a member of `Dir`, so the branch could never run, and a branch that can never run is far more likely to be a typo than a deliberate choice. ```ts declare const d: Dir; // d === "top"; // error: the types have no overlap ``` This is a real argument for modelling small closed sets as literal unions rather than as `string`: with `d: string` the same typo compiles silently and the branch is simply dead. ## Widening, and why the annotation matters Literal types are easy to lose. A `let` binding initialised with a string infers `string`, not the literal, because it is mutable: ```ts let a = "up"; // string const b = "up"; // "up" const c = { dir: "up" }; // { dir: string } const e = { dir: "up" } as const; // { readonly dir: "up" } ``` If the value you compare has been widened to `string`, you get neither the precise narrowing nor the typo error — annotate the parameter or the variable as `Dir` to keep the literal information alive. ## Comparison against a wider type Equality narrowing also applies when the other operand is a plain `string`. Comparing `d` with a `string`-typed variable leaves `d` as `Dir`, because every member of `Dir` is a string and none can be excluded — nothing is gained, and nothing is broken. And going the other way, comparing a `string`-typed variable against a single literal narrows *that* variable to the literal inside the branch: ```ts declare let s: string; if (s === "up") { s; // "up" } ``` ## switch behaves the same way A `switch` on the same variable narrows each `case` clause exactly as the corresponding `if (d === ...)` would, and the `default` clause sees the members no case matched. Using that residue to prove you handled every member is a separate technique with its own idioms; the narrowing underneath it is the plain equality narrowing described here. ## Runtime reality None of this exists after compilation. `Dir` emits nothing, the branch types are erased, and the shipped JavaScript is an ordinary `if (d === "up")`. The value of the literal union is entirely in what the checker refuses to accept before that point — the misspelled comparison, the forgotten branch, the assignment of `"north"` to a `Dir`. ## Interview framing Answer with both branch types, not just the obvious one — candidates routinely give `"up"` for the true branch and forget that the else branch is the narrowed `"down" | "left"`. Then mention the no-overlap error, because it shows you have actually written this code rather than read about it.
- What happens to the type if you keep subtracting members until none are left?It becomes `never`, the type with no values. That is the compiler telling you the branch is unreachable given the declared union, and it is the signal exhaustiveness helpers rely on. Reaching `never` unexpectedly usually means you narrowed something you did not intend to.
- Why does `let d = "up"` not give you the literal type, and how do you keep it?A mutable `let` binding widens the literal to `string` on inference, since you could reassign it. Use `const`, annotate the binding or parameter as the union type, or apply `as const` to an object literal's property to preserve the exact literal type.
saying these in an interview costs you the question
- Says the else branch is still the full union
- Expects `d === "top"` to compile and just be false
- Thinks literal unions exist at runtime as values
- Assumes `let x = "up"` has the literal type "up"
- Confuses a literal union with a string enum's members