In TypeScript, `function f(x: string | number, y: string | boolean)` contains `if (x === y) { ... }`. What are x and y inside that branch, and what would happen if their types shared nothing at all?
answer
- both operands narrow, not just one
- keep what the two types could share
- inequality tells you almost nothing
- no shared values means no compilation
- dead comparison is treated as a mistake
basics
~20 sBoth operands narrow to what they could share: inside the branch x and y are each string. If the two declared types had no values in common, the comparison would not compile — the checker rejects it as unintentional because the types have no overlap.
solid answer
~40 sEquality narrowing applies to **both** sides of the comparison, not just to a variable tested against a literal. Since `x === y` can only be true when the two values are identical, the checker keeps just the constituents the two unions could share: `string` is in both, so inside the branch `x` is `string` and `y` is `string`. The else branch keeps the declared types, because knowing two values differ rules nothing out. If the operands' types had no overlap at all — say `number` against `boolean` — the comparison itself is an error, since it could never be true. Note the asymmetry: `if (x !== y)` narrows nothing in its own branch for two multi-member unions; only its else branch gets the shared narrowing.
code
typescript · 11 linesfunction f(x: string | number, y: string | boolean) {
if (x === y) {
const a: string = x; // only string can be shared
const b: string = y;
void a;
void b;
} else {
const c: string | number = x; // inequality removes nothing
void c;
}
}go deeper
Know that comparing two values with === can teach the compiler about both of them, and that the branch gives you whatever type they could share.
State the branch types for both operands, explain the shared-constituent rule, and describe the asymmetry between === and !== narrowing.
Treat a no-overlap error as a modelling signal rather than an obstacle — dead branch, wrong type, stale constant — and say how you would resolve it without reaching for an assertion.
Own how much the codebase leans on comparison-driven narrowing versus explicit tags, and what that choice costs when types are refactored across module boundaries.
## Narrowing applies to both operands Most people meet equality narrowing as "variable compared with a literal". The rule is more general: when TypeScript sees `===`, `!==`, `==` or `!=` between two expressions it can track, it narrows **each** operand using the other's type, because a true strict comparison means the two values are the same value. ```ts function f(x: string | number, y: string | boolean) { if (x === y) { x; // string y; // string } else { x; // string | number y; // string | boolean } } ``` The reasoning is simple set logic. If `x` were a number, no value of `y` could equal it, since `y` is a string or a boolean. So inside the branch the only surviving possibility for `x` is `string`, and symmetrically for `y`. The compiler has effectively intersected the two unions. ## Why the else branch is unchanged Inequality carries much less information. `x !== y` tells you the two values differ — it does not tell you *what* either one is. `x` can still be a string (just a different string) or a number, so nothing can be removed. That is the practical asymmetry to remember: - `if (x === y)` narrows both operands in the **then** branch. - `if (x !== y)` narrows both operands in the **else** branch, and nothing in the then branch. The exception is when one side is a single-valued type. Comparing against one literal makes inequality informative, because exactly one possibility can be subtracted: ```ts declare let d: "up" | "down" | "left"; if (d !== "up") { d; // "down" | "left" } ``` ## The no-overlap error When the two operand types share no values, TypeScript refuses the comparison outright, reporting that it appears unintentional because the types have no overlap. This is one of the few places the checker rejects an expression that is perfectly valid JavaScript and would simply evaluate to `false`. The rationale is that a comparison that can never be true is far more likely to be a mistake — a renamed enum member, a field compared against the wrong constant, a stale literal after a refactor — than a deliberate choice. A common way to hit it accidentally is over-narrowing: after an earlier guard has already reduced a variable to one member, a later comparison against a different member becomes a no-overlap error, which is the compiler pointing at genuinely dead code. ## Escaping the check deliberately If you truly want to compare values whose static types disagree — for instance because a value crossed an untyped boundary — widen one side explicitly rather than silencing the diagnostic in place. Comparing through a variable typed `unknown` is well behaved: `unknown` overlaps everything, so the comparison is allowed, and nothing is narrowed by it in a way you can dereference without a further guard. Reaching for an `as` assertion instead just tells the compiler a story it will believe, which is exactly the unsoundness this diagnostic exists to prevent. ## Where it shows up in real code The two-operand form is common in generic-feeling helpers: comparing a key against a candidate key, a status against a status held in a variable, an id against another id whose type is broader. In those places the narrowing is a genuine convenience — the branch that proves `x === y` also tells the checker both are strings, so string methods become available without an extra guard. ```ts function sameLabel(a: string | number, b: string | boolean): boolean { if (a === b) { return a.toUpperCase() === b.toUpperCase(); // both string here } return false; } ``` ## And it is all erased The emitted JavaScript contains a plain `===`. The narrowing exists only to decide which members and operations the checker will allow inside the branch; there is no runtime cost and no runtime check. That framing is worth saying explicitly in an interview, because it explains why the compiler is willing to reject a comparison at all: rejecting it is the only leverage it has, since it cannot insert a check for you. ## Interview framing Give the branch types for both operands, name the rule as "each side narrows to what it could share with the other", and then volunteer the no-overlap error and the `!==` asymmetry. Those two details separate someone reciting the handbook example from someone who has felt the compiler push back on their own code.
- Does the then-branch of `if (x !== y)` narrow anything when both are multi-member unions?No. Knowing the values differ rules out no constituent — `x` could still be any of its members, just not the particular value `y` holds. Narrowing on inequality only bites when one side is a single-valued type such as a literal, `null` or `undefined`, where exactly one possibility can be subtracted.
- You genuinely need to compare two values whose types the compiler says have no overlap. What do you do?Fix the model first — a no-overlap error usually means one of the two types is wrong or the branch is dead. If the comparison is legitimate because a value crossed an untyped boundary, route it through a variable typed `unknown`, which overlaps everything, and narrow it properly afterwards rather than papering over it with an `as` assertion.
saying these in an interview costs you the question
- Thinks only the variable, not the literal side, gets narrowed
- Expects the else branch to narrow after an === check
- Says a no-overlap comparison compiles and returns false
- Believes `x !== y` narrows both operands in its own branch
- Fixes a no-overlap error with an `as` assertion