skip to content

Built-in Type Guards

The narrowing TypeScript gives you for free from ordinary JavaScript checks — typeof, instanceof, the in operator, truthiness, and equality. Interviewers start here because these are checks you write anyway, and knowing exactly what each one narrows separates guessing from understanding.

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

questions

14

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"`?

level: juniorimportance: must knowfreq 66%

answer

  1. each literal is its own type
  2. the else branch subtracts the match
  3. keep subtracting and you reach never
  4. a misspelled literal will not compile
  5. widening to string loses all of it

basics

~20 s

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

A 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 lines
typescript
type 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context

open as a page

In TypeScript, given `function fmt(x: string | number)`, what type does the checker give `x` inside `if (typeof x === 'string')` and in the `else` branch, and which strings is the compiler willing to accept on the right-hand side of a `typeof` comparison?

level: juniorimportance: must knowfreq 78%

basics

~20 s

TypeScript narrows x to string inside the if branch and to number in the else branch. Only the eight strings typeof can actually return are accepted; comparing against any other string is reported as a comparison with no overlap.

open as a page

In TypeScript 5.5 or later, `if (isDefined(v)) { v.trim(); }` narrows `v` when the helper is written `function isDefined(x: string | undefined) { return x !== undefined; }`, but reports that `v` may be undefined once you annotate that helper's return type as `: boolean`. What is going on?

level: middleimportance: must knowfreq 55%

basics

~20 s

A boolean-returning helper narrows nothing by itself. TypeScript 5.5 infers a type predicate for a helper that has no return-type annotation, exactly one return statement, an unmutated parameter, and a body whose result refines that parameter. Annotating : boolean suppresses the inference.

open as a page

In TypeScript, given `function f(x: string | number | undefined)`, what type does x have in each branch of `if (x) { ... } else { ... }`, and why does the compiler not complain that 0 and the empty string end up in the else branch?

level: middleimportance: must knowfreq 72%

basics

~20 s

Truthiness narrowing removes only constituents that can never be truthy, so the then-branch is string | number and the else-branch stays string | number | undefined. Since 0 and "" are ordinary values of number and string, the checker sees nothing wrong.

open as a page

In TypeScript with strictNullChecks enabled, how do `if (x === null)`, `if (x === undefined)` and `if (x == null)` differ in what they narrow out of a value declared `string | null | undefined`?

level: middleimportance: must knowfreq 60%

basics

~20 s

Strict comparisons remove one constituent each: after x === null returns false the type is string | undefined, and after x === undefined it is string | null. The loose form x == null is special-cased to cover both, so its false branch is plain string.

open as a page

In TypeScript, how does `if ('radius' in shape)` narrow a union of object types, and why can an optional property leave the member you expected to be removed sitting in the `else` branch?

level: middleimportance: must knowfreq 58%

basics

~20 s

The in operator keeps union members that declare the named property and drops those that do not. When the property is optional, the member can legitimately lack it at runtime, so the checker keeps that member in both branches.

open as a page

In a TypeScript project with `strictNullChecks` on, a value is declared `string[] | string | null`. Which branch of `if (typeof value === 'object')` does the checker put `null` in, what is the narrowed type there, and how do you exclude it?

level: middleimportance: must knowfreq 62%

basics

~20 s

Null lands in the true branch: the checker narrows to string[] | null, because the runtime check it models reports "object" for null. Combine the guard with an explicit non-null check, or test for the primitive member instead.

open as a page

In TypeScript, if you write `const isString = typeof value === "string";` and then `if (isString) { ... }`, does the compiler still narrow `value` inside the block, and what does it require for that to work?

level: middleimportance: should knowfreq 45%

basics

~20 s

Yes. Since TypeScript 4.4 the checker follows a condition stored in a const and re-applies it wherever that const is used, provided the alias is a const without a type annotation and the value it tests is itself effectively constant.

open as a page

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?

level: middleimportance: should knowfreq 42%

basics

~20 s

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

open as a page

In TypeScript, what type does `if (Array.isArray(x))` narrow `x: unknown` to, and why is that a hole in an otherwise strict codebase?

level: middleimportance: should knowfreq 38%

basics

~20 s

It narrows to any[], because the standard library declares the check with the predicate arg is any[]. Every element read out of it is any, so a strict codebase silently loses type safety inside the branch.

open as a page

In TypeScript, `if (typeof obj[key] === "string") { obj[key].toUpperCase(); }` compiles when `key` is declared `const key = "a"`, but fails when `key` is a `let`. Why does the declaration of the key decide this, and why is the narrowing you do get still unsound?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Narrowing an element access requires the key to be a stable literal, so the checker can treat both obj[key] occurrences as the same slot. A const key has a literal type; a let key widens to string and is mutable, so the two accesses are not matched.

open as a page

A TypeScript service paginates with `if (page.cursor)` where `cursor: string` and the API sends `""` on the last page, and a dashboard hides a counter with `if (count)` where `count: number` can legitimately be 0. Beyond patching the two call sites, how do you use the type layer so this class of bug cannot keep recurring?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Stop encoding absence as a falsy in-band value. Model the missing state as null or an optional property so the checker can see it, normalise the API's "" at the boundary, and require presence checks like x != null instead of truthiness, which narrows types and never values.

open as a page

In TypeScript, `pet` is declared `Dog | Cat`, where both classes declare exactly the same members. Why does `if (pet instanceof Dog)` fail to narrow `pet` to `Dog`, and what change makes the narrowing work?

level: seniorimportance: should knowfreq 42%

basics

~20 s

TypeScript's type system is structural, so two classes with identical members are interchangeable types. Narrowing keeps every union member compatible with the target, which is both of them. Giving one class a private or #private field makes them incompatible and restores the narrowing.

open as a page

TypeScript's narrowing through const-aliased conditions, constant-key element accesses and inferred predicates can silently disappear when someone changes a `const` to a `let` or adds a second `return` to a helper. Across a large codebase, how do you decide where to rely on inferred narrowing and where to make the narrow type explicit?

level: principalimportance: nice to knowfreq 22%

basics

~20 s

Rely on inferred narrowing where the check and its use are visible together inside one function, and make the narrow type the declared type wherever data crosses a module or API boundary, so the guarantee lives in a signature rather than in a body someone can edit.

open as a page