skip to content

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%

answer

  1. structure, not name, decides identity
  2. the constituent is still assignable
  3. one class needs something the other lacks
  4. a private member breaks compatibility

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.

solid answer

~40 s

Narrowing by `instanceof` filters the union by *assignability*, and TypeScript compares types by structure, not by name. If `Cat` declares the same members as `Dog`, then a `Cat` is assignable to `Dog`, so the guard cannot eliminate it and the branch stays `Dog | Cat`. The check itself is real and works at runtime — the checker simply cannot express the distinction, because at the type level the two classes *are* the same type. The fix is to make them structurally different: a `private` member or an ECMAScript `#private` field makes a class incompatible with any class that does not declare that exact member, and narrowing works again. A required literal discriminant field achieves the same for plain object unions. Mechanically, `instanceof` narrows to the type of the right operand's `prototype` property.

code

typescript · 16 lines
typescript
class Dog {
  #dog = true;
  constructor(public name: string) {}
}

class Cat {
  #cat = true;
  constructor(public name: string) {}
}

function speak(pet: Dog | Cat): string {
  if (pet instanceof Dog) {
    return `${pet.name} barks`; // pet: Dog
  }
  return `${pet.name} meows`; // pet: Cat
}

go deeper

for a junior

Know that the operator's right-hand side must be a class or constructor, never an interface, and that it narrows a union to the matching class instance type.

for a middle

Explain that narrowing filters union members by structural assignability, so two classes with identical members cannot be separated, and name the private-member fix.

for a senior

Show the production judgment: brand domain classes deliberately — with a #private field or a required discriminant — so guards stay meaningful, and recognise that the checker trusts an instanceof result it cannot verify.

for a principal

Own the modelling policy: decide where the codebase relies on nominal-style identity versus discriminated data, and keep values that cross module or process boundaries on tag-based discrimination rather than constructor identity.

## The setup ```ts class Dog { constructor(public name: string) {} } class Cat { constructor(public name: string) {} } function speak(pet: Dog | Cat) { if (pet instanceof Dog) { // pet is still Dog | Cat } } ``` The guard looks textbook and does nothing useful. Understanding why is a good test of whether a candidate really believes that TypeScript is structural. ## Assignability, not names When the checker narrows a union with any guard, it walks the constituents and keeps those compatible with the guard's target type. Compatibility here is structural: `Dog` and `Cat` declare the same public members, so a value of type `Cat` is assignable to `Dog` and vice versa. There is nothing in the *type* of `Cat` that says "this is not a Dog" — the class name is not part of its type identity. So the constituent cannot be filtered out, and the branch keeps both. This is not a defect in `instanceof` support. The runtime check is perfectly capable of telling the two apart; the *type system* is the part that cannot express the difference. Narrowing can only ever be as precise as the types involved. ## What instanceof narrows to Mechanically, the right operand must be a value whose type has a construct signature (or `any`), and the type the guard narrows to is the type of that value's `prototype` property. This is why `instanceof` works with a class, and why it also works with a constructor-typed variable rather than only with a literal class name. It also explains subclass narrowing: for `x: Animal` and `class Dog extends Animal`, the guard narrows to `Dog` because `Dog.prototype` has the more specific type. ## The fix: make the classes structurally distinct A `private` member, or an ECMAScript `#private` field, is part of a class's type identity. Two classes that each declare a private member of the same name are *not* compatible — the declarations come from different classes, so the types are incompatible even when the names match. ```ts class Dog { #dog = true; constructor(public name: string) {} } class Cat { #cat = true; constructor(public name: string) {} } function speak(pet: Dog | Cat) { if (pet instanceof Dog) { // pet: Dog } else { // pet: Cat } } ``` This is the same trick as a brand on a plain type: the extra member exists only to stop the structural comparison from succeeding. The `#private` form has the advantage of being genuinely private at runtime as well, so nothing outside the class can forge it. When the values are plain objects rather than class instances, the equivalent — and usually better — answer is a required literal discriminant field, which makes the two types incompatible *and* gives you an equality check that a `switch` can exhaust. ## Related traps worth naming The guard's target must be a constructor. Interfaces are erased, so `x instanceof SomeInterface` is not merely unhelpful — there is no value to put on the right-hand side at all, and the code does not compile. Candidates who reach for it have not internalised that the type layer is erased. Separately, `instanceof` relies on runtime identity of the constructor, which can fail in situations the checker cannot see — the classic ones are values crossing a realm boundary or a bundle containing two copies of the same class. Those are runtime concerns rather than type-layer ones; from the checker's side the guard is simply trusted, which means a `false` result there silently narrows to the *wrong* branch with no complaint. Knowing that the compiler cannot help you with it is the type-level takeaway. ## Interview framing Lead with the reason — structural typing means the two classes are one type — rather than with the fix. Then give the fix (a private or `#private` member, or a discriminant for object unions), and add the mechanical detail that narrowing targets the `prototype` property's type. Mentioning that the check works fine at runtime while the type layer stays ambiguous shows you can separate the two layers, which is what the question is really probing.

  • Why does adding a `#private` field fix this when adding an ordinary public marker property might not?
    A `#private` field is part of the class's type identity and can never be satisfied by another declaration, so the two classes become permanently incompatible. A public marker would work too, but only while nothing else declares the same property with the same type — and it is visible and forgeable by any caller. The private form is both stronger and non-public API.
  • Can you write `x instanceof MyInterface` in TypeScript?
    No. An interface exists only in the type layer and emits nothing, so there is no runtime value to place on the right of the operator and the code does not compile. The operator needs a constructor. To check an interface-shaped value you need a different mechanism — a property-presence check, a discriminant field, or a hand-written predicate.
  • What does the checker actually use as the narrowed type for an `instanceof` guard?
    The type of the right operand's `prototype` property. The right operand must be a value whose type has a construct signature, or `any`. That is why the guard works with a constructor held in a variable and not only with a class name written inline, and why narrowing to a subclass follows automatically from the subclass's more specific prototype type.

saying these in an interview costs you the question

  • Says instanceof is broken in TypeScript
  • Believes classes are compared by name rather than structure
  • Tries to use an interface on the right of instanceof
  • Thinks the guard emits an extra runtime type check
  • Assumes the else branch is safe when the check crosses realms

context