Narrowing and Type Guards
How TypeScript narrows a union to one type: typeof, instanceof and in checks, discriminated unions, exhaustiveness checks with never, and custom type guards.
What narrowing is
A
union type
such as string | number only lets you use what both
members have in common. To do something specific, you check which
member you have, with ordinary JavaScript. TypeScript follows that
check: inside the branch where it is true, the variable has the
narrower type. That is narrowing.
function formatKWh(value: string | number): string {
if (typeof value === "string") {
// here value is string
return `${parseFloat(value).toFixed(1)} kWh (parsed)`;
}
// here value is number
return `${value.toFixed(1)} kWh`;
}
console.log(formatKWh(4.2));
console.log(formatKWh("12.04"));
4.2 kWh 12.0 kWh (parsed)
Nothing new is written for the compiler; the
typeof check is plain JavaScript and stays in the
output. What makes it work is
control flow analysis:
the compiler follows every path through the function (each
if, else, return,
throw and switch case) and tracks which
types a variable can still have at each point. After the
if block returns, only number is left, so
value.toFixed is allowed without an else.
true branch returns, only the
number path reaches the code after the
if.
The check has to make sense, too. Inside the branch, the compiler holds you to the narrowed type:
function formatKWh(value: string | number): string {
if (typeof value === "string") {
return value;
}
return value.toUpperCase();
}
example.ts(5,16): error TS2339: Property 'toUpperCase' does not exist on type 'number'.
An expression that narrows a type in this way is called a type guard. The rest of this page walks through the built-in ones, then shows how to write your own.
typeof, truthiness and equality
typeof
The
typeof operator
returns a string such as "string",
"number", "boolean",
"undefined", "object" or
"function". TypeScript understands each of these
results, also in a switch:
function describe(value: string | number | boolean | null | undefined): string {
switch (typeof value) {
case "string":
return `text "${value.trim()}"`;
case "number":
return `number ${value.toFixed(2)}`;
case "boolean":
return value ? "yes" : "no";
default:
return `nothing (${value})`;
}
}
for (const v of [" E-1001 ", 4.2, true, null, undefined]) {
console.log(describe(v));
}
text "E-1001" number 4.20 yes nothing (null) nothing (undefined)
One JavaScript quirk carries over: typeof null is
"object". A typeof x === "object" check
therefore narrows to "object or null", and TypeScript
will remind you to rule out null as well.
Truthiness
if (note) narrows away null and
undefined, because both are
falsy. It is short and common, but it also rejects the other falsy
values: "", 0 and NaN. For
strings that is often fine. For numbers it is a real bug, because 0
is a valid reading:
function kWhText(kWh: number | undefined): string {
if (!kWh) {
return "no reading";
}
return `${kWh} kWh`;
}
console.log(kWhText(4.2));
console.log(kWhText(undefined));
console.log(kWhText(0));
4.2 kWh no reading no reading
Equality
Comparing with === or !== narrows as well.
Testing against undefined exactly is the precise
version of the check above, and it keeps the 0:
function kWhText(kWh: number | undefined): string {
if (kWh === undefined) {
return "no reading";
}
return `${kWh} kWh`;
}
console.log(kWhText(4.2));
console.log(kWhText(undefined));
console.log(kWhText(0));
4.2 kWh no reading 0 kWh
Comparing with a literal (status === "open") narrows a
literal union to that one value, which is the basis of
discriminated unions below. The
loose comparison x == null is the one accepted use of
== in strict code: it is true for both
null and undefined, and TypeScript narrows
both away.
instanceof and in
instanceof
For values created by a class (including built-ins such as
Date, Map and Error),
x instanceof C checks the
prototype chain
at run time, and TypeScript narrows to C:
function when(at: string | Date): string {
if (at instanceof Date) {
return at.toISOString().slice(0, 10);
}
return at.slice(0, 10);
}
console.log(when(new Date(Date.UTC(2026, 9, 5))));
console.log(when("2026-10-06T08:15:00Z"));
2026-10-05 2026-10-06
The place you will use instanceof most is a
catch block. Anything can be thrown in JavaScript, so
in strict mode the caught value has the type unknown,
and you must narrow it before reading .message:
try {
JSON.parse("{ broken");
} catch (err) {
console.log(err.message);
}
example.ts(4,15): error TS18046: 'err' is of type 'unknown'.
function parseKWh(text: string): number {
const value = Number(text);
if (Number.isNaN(value)) {
throw new RangeError(`not a number: "${text}"`);
}
return value;
}
for (const input of ["4.2", "four"]) {
try {
console.log(parseKWh(input));
} catch (err) {
if (err instanceof RangeError) {
console.log(`Rejected: ${err.message}`);
} else {
throw err;
}
}
}
4.2 Rejected: not a number: "four"
Catching only the errors you expect and rethrowing the rest is the pattern from Error Handling; TypeScript just makes the check mandatory.
in
Interfaces and object types have no run-time existence, so
instanceof cannot test for them. What you can test is
whether a property exists: "homes" in e. TypeScript
narrows a union of object types to the members that have (or may
have) that property:
interface Outage {
ticket: string;
homes: number;
}
interface Reading {
meterId: string;
kWh: number;
}
function describe(e: Outage | Reading): string {
if ("homes" in e) {
return `${e.ticket}: ${e.homes} homes without power`;
}
return `${e.meterId}: ${e.kWh} kWh`;
}
console.log(describe({ ticket: "INC-30418", homes: 120 }));
console.log(describe({ meterId: "E-1001", kWh: 4.2 }));
INC-30418: 120 homes without power E-1001: 4.2 kWh
This works, but it is fragile: add a homes property to
Reading one day and the check silently means something
else. When you design the types yourself, a dedicated tag property
is clearer.
Discriminated unions
A discriminated union
(also called a tagged union) is a union of object types that all
share one property with a different
literal type
in each member, usually named kind,
type or status. Checking that one property
narrows the whole object to the matching member. It is the standard
TypeScript way to model "one of several kinds of thing", such as
events, messages, API results and state machines:
type Event =
| { kind: "outage"; ticket: string; homes: number }
| { kind: "reading"; meterId: string; kWh: number }
| { kind: "tariff-change"; customer: string; from: string; to: string };
function describe(e: Event): string {
switch (e.kind) {
case "outage":
return `${e.ticket}: ${e.homes} homes without power`;
case "reading":
return `${e.meterId}: ${e.kWh} kWh`;
case "tariff-change":
return `${e.customer}: ${e.from} → ${e.to}`;
}
}
const events: Event[] = [
{ kind: "outage", ticket: "INC-30418", homes: 120 },
{ kind: "reading", meterId: "E-1001", kWh: 4.2 },
{ kind: "tariff-change", customer: "C-20417", from: "fixed", to: "dynamic" },
];
events.forEach((e) => console.log(describe(e)));
INC-30418: 120 homes without power E-1001: 4.2 kWh C-20417: fixed → dynamic
Inside each case, only that member's properties are
available, so mixing them up is caught. The tag is real data, so the
check also works on objects that came from JSON, where no class or
instanceof could help:
type Event =
| { kind: "outage"; ticket: string; homes: number }
| { kind: "reading"; meterId: string; kWh: number };
function describe(e: Event): string {
if (e.kind === "outage") {
return `${e.ticket}: ${e.kWh}`;
}
return e.meterId;
}
example.ts(7,30): error TS2339: Property 'kWh' does not exist on type '{ kind: "outage"; ticket: string; homes: number; }'.
Exhaustiveness checks
What happens when someone adds a fourth status? Every
switch that handles statuses should get a new case, and
an
exhaustiveness check
makes the compiler find the ones that did not. The first line of
defense is free: when a function has a return type and a case is
missing, the end of the function is reachable without a
return:
type Status = "open" | "in-progress" | "resolved";
function color(s: Status): string {
switch (s) {
case "open":
return "red";
case "resolved":
return "green";
}
}
example.ts(3,28): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.
The explicit technique uses the type
never, the type with no values. After all cases have been handled,
narrowing leaves nothing, so the variable's type in the
default branch is never. Pass it to a
function that only accepts never. As long as every case
is handled, that compiles. Add "on-hold" to the union
and forget it in the switch, and the leftover type is no longer
never:
type Status = "open" | "in-progress" | "resolved" | "on-hold";
function assertNever(value: never): never {
throw new Error(`Unexpected status: ${JSON.stringify(value)}`);
}
function color(s: Status): string {
switch (s) {
case "open":
return "red";
case "in-progress":
return "orange";
case "resolved":
return "green";
default:
return assertNever(s);
}
}
example.ts(16,26): error TS2345: Argument of type '"on-hold"' is not assignable to parameter of type 'never'.
The error names exactly the case you forgot. The
throw inside assertNever is not wasted
either. Types do not exist at run time, so if bad data slips past
the compiler, for instance a status from an API that was cast with
as, the program fails loudly at the right spot instead
of returning undefined:
type Status = "open" | "in-progress" | "resolved";
function assertNever(value: never): never {
throw new Error(`Unexpected status: ${JSON.stringify(value)}`);
}
function color(s: Status): string {
switch (s) {
case "open":
return "red";
case "in-progress":
return "orange";
case "resolved":
return "green";
default:
return assertNever(s);
}
}
// Data from outside: the compiler cannot check it
const fromApi = JSON.parse('["open", "resolved", "on-hold"]') as Status[];
for (const s of fromApi) {
console.log(`${s}: ${color(s)}`);
}
open: red resolved: green Error: Unexpected status: "on-hold"
Custom type guards with type predicates
Sometimes the check is more than one expression, or you need it in
several places. Put it in a function whose return type is a
type predicate,
parameter is Type. The function returns a boolean at
run time; for the compiler, true means "the argument
has this type":
interface Outage {
kind: "outage";
ticket: string;
homes: number;
}
interface Reading {
kind: "reading";
meterId: string;
kWh: number;
}
type Event = Outage | Reading;
function isOutage(e: Event): e is Outage {
return e.kind === "outage";
}
const events: Event[] = [
{ kind: "outage", ticket: "INC-30418", homes: 120 },
{ kind: "reading", meterId: "E-1001", kWh: 4.2 },
{ kind: "outage", ticket: "INC-30421", homes: 35 },
];
const outages = events.filter(isOutage);
const homes = outages.reduce((sum, o) => sum + o.homes, 0);
console.log(`${outages.length} outages, ${homes} homes`);
2 outages, 155 homes
Passing the guard to filter changes the result type to
Outage[], which is why o.homes is allowed.
For simple cases you do not even need to write the predicate: since
TypeScript 5.5 the compiler infers it from an arrow
function such as (r) => r != null. Older articles
that tell you to write (r): r is number => … for
this are out of date:
const readings = [4.2, null, 3.1, undefined, 5.0];
const valid = readings.filter((r) => r != null);
const total = valid.reduce((sum, r) => sum + r, 0);
console.log(total.toFixed(1));
12.3
Guarding unknown data
The most important use of a type guard is at a trust boundary: data
parsed from JSON, received from an API or typed by a user has the
type unknown until you have checked it. A guard on
unknown checks every property you rely on, and each
check narrows a little further:
interface Ticket {
id: string;
priority: number;
}
function isTicket(value: unknown): value is Ticket {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
typeof value.id === "string" &&
"priority" in value &&
typeof value.priority === "number"
);
}
const body: unknown = JSON.parse('[{"id":"INC-30412","priority":2},{"id":"INC-30415","priority":"high"}]');
if (Array.isArray(body)) {
for (const item of body) {
if (isTicket(item)) {
console.log(`${item.id} P${item.priority}`);
} else {
console.log("Rejected:", JSON.stringify(item));
}
}
}
INC-30412 P2
Rejected: {"id":"INC-30415","priority":"high"}
For larger shapes, hand-written guards get long; schema validation
libraries generate the check and the type from one definition. Both
approaches, and how they fit with fetch, are on
Async Code and Typing API Data.
interface Ticket {
id: string;
priority: number;
}
function isTicket(value: any): value is Ticket {
return typeof value.id === "string";
}
const item: unknown = { id: "INC-30415", priority: "high" };
if (isTicket(item)) {
console.log(item.priority.toFixed(0));
}
TypeError: item.priority.toFixed is not a function
Where narrowing stops
Narrowing is tied to the flow of the code. It lasts until something
could have changed the value. Inside a callback that runs
later, a let variable may have been reassigned
in the meantime, so a narrowing from outside does not always carry
over:
function findReading(id: string): number | undefined {
return id === "E-1001" ? 4.2 : undefined;
}
let kWh = findReading("E-1001");
if (kWh !== undefined) {
setTimeout(() => console.log(kWh.toFixed(1)), 0);
}
kWh = findReading("E-9999");
example.ts(7,32): error TS18048: 'kWh' is possibly 'undefined'.
The compiler is right: by the time the timer fires,
kWh holds the result of the second lookup, which is
undefined. Without that last assignment, the same
callback compiles, because since TypeScript 5.4 narrowing is kept in
closures created after the last assignment. The simplest habit is to
use const wherever you can: a const can
never change, so its narrowing always holds.
Two other things end a narrowing: assigning a new value to the
variable (its type is then the type of what you assigned, within the
declared type), and, for properties such as
obj.status, the narrowed type is not trusted inside
callbacks either. Copying the property into a const
first avoids both problems.
Putting it together
| You have | Narrow with | Example |
|---|---|---|
| A union of primitives | typeof |
typeof id === "number" |
| A possibly missing value | Equality (or truthiness for strings and objects) |
kWh !== undefined, x != null
|
| Class instances, errors, dates | instanceof |
err instanceof RangeError |
| A union of object types you control | A discriminant property | switch (e.kind) |
| Object types you do not control | in |
"homes" in e |
unknown data from outside |
A type predicate (or a schema library) | isTicket(item) |
In all cases the check is real JavaScript that runs, and TypeScript
reads it. That is the opposite of a type assertion
(as), which changes what the compiler believes without
checking anything. Prefer narrowing, and keep assertions for the
rare cases where you know more than the compiler can see (see
Enums, const Assertions and satisfies).