DEV Community

Cover image for Stop trying `typeof x === Fish`: A practical guide to TypeScript type verification (Narrowing + Type Predicates)
Kelvyn Thai
Kelvyn Thai

Posted on

Stop trying `typeof x === Fish`: A practical guide to TypeScript type verification (Narrowing + Type Predicates)

When you come from a “typed mindset”, it’s natural to want something like:

  • typeof animal === Fish

But JavaScript doesn’t work that way.

1) The key idea: types don’t exist at runtime

TypeScript types are erased after compilation. At runtime, you only have JavaScript values.

So runtime checks are always things like:

  • typeof x === "string"
  • x instanceof Date
  • "swim" in animal
  • animal.kind === "fish"

TypeScript then uses those checks to narrow union types.


2) Narrowing: TypeScript understands common JS patterns

typeof narrowing (primitives)

function padLeft(padding: number | string, input: string) {
  if (typeof padding === "number") return " ".repeat(padding) + input;
  return padding + input;
}
Enter fullscreen mode Exit fullscreen mode

instanceof narrowing (classes / constructors)

function logValue(x: Date | string) {
  if (x instanceof Date) return x.toUTCString();
  return x.toUpperCase();
}
Enter fullscreen mode Exit fullscreen mode

"in" narrowing (property existence)

type Fish = { swim: () => void };
type Bird = { fly: () => void };

function move(animal: Fish | Bird) {
  if ("swim" in animal) animal.swim();
  else animal.fly();
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Note: in works on the prototype chain and optional props affect both branches.


3) Type predicates: make narrowing reusable (the real win)

Sometimes you want to reuse a check across multiple places (especially in .filter()).

That’s where type predicates (user-defined type guards) shine:

type Fish = { swim: () => void };
type Bird = { fly: () => void };

function isFish(animal: Fish | Bird): animal is Fish {
  return "swim" in animal;
}

function move(animal: Fish | Bird) {
  if (isFish(animal)) animal.swim();
  else animal.fly();
}
Enter fullscreen mode Exit fullscreen mode

Now the same function can be reused:

const zoo: (Fish | Bird)[] = [/* ... */];
const fishes = zoo.filter(isFish); // Fish[]
Enter fullscreen mode Exit fullscreen mode

or

type ButtonAsLink = { href: string; onClick?: never };
type ButtonAsAction = { onClick: () => void; href?: never };
type Props = { label: string } & (ButtonAsLink | ButtonAsAction);

function isLinkProps(p: Props): p is Props & ButtonAsLink {
  return "href" in p;
}

function SmartButton(props: Props) {
  if (isLinkProps(props)) {
    return <a href={props.href}>{props.label}</a>;
  }
  return <button onClick={props.onClick}>{props.label}</button>;
}


Enter fullscreen mode Exit fullscreen mode

4) Best practice when you control the model: discriminated unions

If you can change the data shape, this is the most robust approach:

type Fish = { kind: "fish"; swim: () => void };
type Bird = { kind: "bird"; fly: () => void };

function move(animal: Fish | Bird) {
  if (animal.kind === "fish") animal.swim();
  else animal.fly();
}
Enter fullscreen mode Exit fullscreen mode

This is clearer than property checks and scales well as unions grow.


5) Common pitfalls (learn these once)

  • typeof null === "object" (historic JS quirk)
  • !value checks falsy (0, "", false) — not just null/undefined
  • "prop" in obj can be true because of prototypes
  • Optional properties can cause both branches to still include a type (e.g., Human can swim?())

Takeaway

Runtime verification comes from JavaScript checks.
Compile-time safety comes from TypeScript narrowing.
When you want reuse, wrap it in a type predicate.

If you remember one line:

“Use JS checks to narrow, and type predicates to reuse the narrowing.”

Top comments (0)