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 animalanimal.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;
}
instanceof narrowing (classes / constructors)
function logValue(x: Date | string) {
if (x instanceof Date) return x.toUTCString();
return x.toUpperCase();
}
"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();
}
⚠️ 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();
}
Now the same function can be reused:
const zoo: (Fish | Bird)[] = [/* ... */];
const fishes = zoo.filter(isFish); // Fish[]
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>;
}
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();
}
This is clearer than property checks and scales well as unions grow.
5) Common pitfalls (learn these once)
-
typeof null === "object"(historic JS quirk) -
!valuechecks falsy (0, "", false) — not just null/undefined -
"prop" in objcan 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)