Before you begin
Why this matters
Most TypeScript code works with values that can be one of several types: a number or undefined, a meeting or a day off, data from a server. Knowing what a check tells the compiler, and what it does not check while the program runs, tells you when a compile error is protecting you and when the compiler is only repeating what you told it.
What this helps you reason about
- Predict the type of a value after typeof, ===, a truthiness test, in, instanceof, Array.isArray, or an early return.
- Write a type guard or a never check, and say what each one cannot catch.
- Spot a check that drops 0 or "", a guard that tests the wrong thing, and a callback that loses a narrowed type.
In Java or C#, a variable keeps the type it was declared with, and you write a cast or a new variable to use it as a narrower type. TypeScript works the other way: the same name gets a smaller type inside an if, because the compiler reads the check. Static and dynamic type checks used typeof this way on a value from JSON.parse, and Alternatives and combinations used a switch on kind. This lesson shows which checks the compiler reads, how far each one reaches, and where the compiler believes a check that is wrong. The examples come from an office team calendar, where each entry is a meeting, a day off, or a reminder.
Already know this? The short version
- The compiler narrows a union type after typeof, ===, !==, a truthiness test, in, instanceof, and Array.isArray, and control flow analysis carries the smaller type past an early return; typeof x === "object" still allows null.
- if (!x) removes undefined and null from the type, but 0, "", and NaN take the same path; compare with undefined when 0 or "" are valid values.
- A check of a literal kind narrows the whole object; a never parameter in the default makes a forgotten kind a compile error, but a kind that only the data has still reaches the default while the program runs.
- A type guard (x is T) and an assertion function (asserts x is T) are believed without any check of their test, and as narrows nothing. Inside a callback, a let that is assigned after the callback is created keeps its declared type; copy it to a const.
- The section “What goes wrong with narrowing in real programs?” covers guards for JSON, truthiness, silenced callback errors, and a new union member.
Words in this lesson (8)
- union type
- A type made of several types joined with |, such as number | undefined: the value is a number, or it is undefined.
- narrowing
- The compiler treating a value as a smaller type in the code that runs only after a check passed, such as string inside if (typeof x === "string"). The value itself does not change.
- control flow analysis
- The compiler following every path through a function, including early returns, to work out which checks have passed at each line, and so which type each value has there.
- discriminated union
- A union of object types that all have one property, often kind, with a different literal type in each, such as { kind: "weighed"; … } | { kind: "counted"; … }. Checking that property tells the compiler which alternative a value is.
- never
- The type that has no values. A value of type never can appear only in code that can never run, so assigning to a never variable checks that no case is left.
- type guard
- A function whose return type is written x is T, such as entry is Meeting. Where it returns true, the compiler treats x as T. The compiler does not check that the function’s test is right.
- assertion function
- A function whose return type is written asserts x is T. It must throw an error when x is not a T, so after a call that returns, the compiler treats x as T.
- type assertion
- An expression such as value as Flight, which tells the compiler to treat a value as a given type. It checks nothing while the program runs, so the value can still have another type.
Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.
01 / Narrowing
How does a check change the type of a value?
A union type joins several types with |: number | string is a number or a string. The compiler accepts only operations that work for every member, so length / 60 is refused for a number | string. Static and dynamic type checks showed the way out: check the value first.
Inside if (typeof length === "string"), the compiler treats length as a string. This is narrowing: in the code that runs only after a check passed, a value has a smaller type. The value does not change, and nothing new runs; typeof is ordinary JavaScript that runs either way. The compiler only reads the check.
Narrowing also reaches past the if. When the if ends with return, the lines after it run only when the check failed, so there length is a number. The compiler finds this by control flow analysis: it follows every path through the function to see which checks have passed at each line.
Other checks narrow too. length === undefined and length !== undefined compare with one value. A truthiness test, if (x), removes undefined and null from the type, as Conditions and truthiness explained for falsy values. It also sends 0, "", and NaN down the false path, and their type stays number or string, so the compiler cannot warn you.
function lengthText(length: number | string): string {
if (typeof length === "string") {
return length;
}
// only a number reaches this line
const hours = length / 60;
return hours + " h";
} A check narrows the type in the code that runs only after it passed, including every line after an early return.
Coming from Java: Java needs a new name to use a narrower type, as in obj instanceof String s; TypeScript narrows the name you already have.
Coming from JavaScript: The checks are the same JavaScript you already write. TypeScript adds no code; it reads the checks and changes the type it uses for each line.
Precisely: what the compiler reads
typeof narrows for "string", "number", "bigint", "boolean", "symbol", "undefined", "function", and "object". x == null, with two equals signs, removes both null and undefined. Since TypeScript 4.4, a check stored in a const also narrows: const isText = typeof length === "string"; if (isText) { … }. Assigning a value narrows as well: after let room: string | undefined = "North", room is a string until the next assignment. The stepper uses TypeScript 6.0 with strict on.
Experiment
Narrow a parameter with typeof and an early return
Inside the if, the compiler treats length as a string. The if ends with return, so control flow analysis knows that the lines after it run only for a number, and length / 60 is allowed. Without the return, a string can reach the division, and the compiler refuses it.
Why it matters An early return narrows the rest of the function. When you remove one, the compiler checks the lines below it again.
Predict which return runs for each call, and what prints. Then delete the line return length; and predict whether the program still compiles.
function lengthText(length: number | string): string {
if (typeof length === "string") {
return length;
}
const hours = length / 60;
return hours + " h";
}
console.log(lengthText(90), lengthText("all day"));
What to look for
Step into lengthText. For the first call, length shows 90, and the stop after the if line is line 5: line 3 never runs. hours shows not initialized yet until line 5 has run, then 1.5. For the second call, length shows "all day", and the stop after the if line is line 3. The run prints 1.5 h all day. The stepper sometimes stops on the same line twice; that is normal for this debugger. After the edit, nothing runs, and the compiler reports Line 4: The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.
Expected output
1.5 h all day02 / Objects and arrays
How do you narrow objects and arrays?
Objects need other checks. Array.isArray(who) is true when who is an array, so it narrows string | string[] to string[] inside the if, and to string after it. instanceof tests, while the program runs, whether an object was made by a class, as Constructors and creating values made objects with new. Date is a built-in class for a moment in time, so start instanceof Date narrows Date | string to Date.
"room" in entry is true when the object entry has a property named room. On a union of object types, it keeps the members that have room.
typeof has one surprise: typeof null is "object". So after typeof data === "object", data can still be null, and "room" in data does not compile. The compiler reports 'data' is possibly 'null'. Write typeof data === "object" && data !== null.
function whoText(who: string | string[]): string {
if (Array.isArray(who)) {
return who.length + " people";
}
return who;
}
function startText(start: Date | string): string {
if (start instanceof Date) {
return "at " + start.getHours() + ":00";
}
return start;
} Narrow arrays with Array.isArray, class instances with instanceof, and objects with in, after ruling out null.
Precisely: in on unknown, and instanceof
Since TypeScript 4.9, "room" in data on an object of unknown shape narrows data to an object with a room property of type unknown, so data.room can be read and checked further. instanceof follows the prototype chain while the program runs, so it fails for an object made in another realm, such as an iframe, and for plain objects from JSON, which no class made. That is why Array.isArray, not instanceof Array, is the check for arrays.
03 / Kinds
How does a kind property narrow a whole object?
Alternatives and combinations showed a discriminated union: object types that share a kind property, with a different literal type in each, and a switch on kind that lets each case read its own fields. The same narrowing works with if and an early return: after if (entry.kind === "meeting") return …;, the rest of the function knows that entry is not a meeting.
The kind must have a literal type. If one member says kind: string, a check of kind === "meeting" cannot rule that member out, because its kind can be any text, so entry.room does not compile.
After every kind has a case, nothing is left, and in the default entry has the type never. Passing it to a function that takes never compiles. When someone adds a kind to the type and forgets a case, the default no longer gets never, and the call becomes a compile error that names the missing kind.
That check happens before the run, against the type. Data from outside can carry a kind that the type does not list. Then the default runs while the program runs, with a real value in a parameter whose type has no values. So make the never function return or throw something sensible, not only satisfy the compiler.
function unexpected(entry: never): string {
return "unknown entry " + JSON.stringify(entry);
}
function entryText(entry: Entry): string {
switch (entry.kind) {
case "meeting":
return "meeting in " + entry.room;
case "dayOff":
return entry.person + " is away";
default:
return unexpected(entry);
}
} A literal kind narrows the whole object; a never check catches a kind the type lists and the code forgets, not a kind that only the data has.
Coming from Rust: A Rust match on an enum fails to compile when a variant is missing; a TypeScript switch is checked only when you add a never check or rely on a missing return, and either check sees only the type.
Precisely: other forms that narrow
Since TypeScript 4.6, const { kind } = entry; if (kind === "meeting") narrows entry too, when entry is not reassigned. Since TypeScript 5.3, switch (true) with case typeof x === "string": narrows like an if. JSON.stringify(entry) writes the object as JSON text, which is how unknown entry {"kind":"training"} is printed in the experiment.
Experiment
Send a kind the type does not list to a never check
After the two cases, the compiler has removed both alternatives, so entry has the type never in the default, and passing it to a never parameter compiles. as Entry[] checked nothing, so the training entry arrived anyway, and the default ran for it. With training in the type, entry in the default can be a training entry, which is not never, and the compiler names it.
Why it matters A never check finds a kind that your type lists and your code forgets. It cannot find a kind that only the data has; for that, the default needs code that does something sensible while the program runs.
The JSON text has a kind that Entry does not list. Predict the two printed lines, and whether unexpected ever runs. Then add a third line to the Entry type, | { kind: "training"; topic: string }, and predict whether the program compiles.
type Entry =
| { kind: "meeting"; room: string }
| { kind: "dayOff"; person: string };
function unexpected(entry: never): string {
return "unknown entry " + JSON.stringify(entry);
}
function entryText(entry: Entry): string {
switch (entry.kind) {
case "meeting":
return "meeting in " + entry.room;
case "dayOff":
return entry.person + " is away";
default:
return unexpected(entry);
}
}
const text = '[{"kind": "meeting", "room": "North"}, {"kind": "training"}]';
const entries = JSON.parse(text) as Entry[];
for (const entry of entries) {
console.log(entryText(entry));
}
What to look for
Step past line 21: entries shows [{kind: "meeting", room: "North"}, {kind: "training"}]. On the loop line, entry shows not initialized yet before each pass. In entryText the stepper does not stop on case lines. For the meeting, the stop after the switch line is line 12. For the training entry, it is line 16, and the next stop is in unexpected, whose frame shows entry {kind: "training"}: a real value in a parameter whose type has no values. The frame of entryText also lists unexpected, because entryText uses it. The run prints meeting in North, then unknown entry {"kind":"training"}. After the edit, nothing runs, and the compiler reports Line 17: Argument of type '{ kind: "training"; topic: string; }' is not assignable to parameter of type 'never'.
Expected output
meeting in North
unknown entry {"kind":"training"}04 / Type guards
What happens when you write the check yourself?
Some checks are too long to repeat. A type guard is a function that returns a boolean and whose return type is written entry is Meeting. Where the guard returns true, the compiler treats entry as a Meeting; where it returns false, it removes Meeting from the type.
The compiler does not read the guard’s test to see whether it matches the promise. Any boolean will do. A guard that tests the wrong thing is a false statement that the compiler believes, and it gives no error at all. The experiment below shows one.
An assertion function is the same promise for code that should stop. Its return type is written asserts entry is Meeting, and it must throw when the entry is not a meeting. throw new Error("…") ends the function, and the program unless code around the call catches the error. After a call that returns normally, the compiler treats entry as a Meeting.
entry as Meeting is not narrowing. A type assertion changes what the compiler believes and checks nothing, on any path. A guard at least runs a test; as runs none.
function isMeeting(entry: Entry): entry is Meeting {
return entry.kind === "meeting";
}
function assertMeeting(entry: Entry): asserts entry is Meeting {
if (entry.kind !== "meeting") {
throw new Error("not a meeting: " + entry.kind);
}
} The compiler believes a type guard or an assertion function without checking its test; as is a belief with no test at all.
Coming from Python: A TypeGuard function checked by mypy works the same way: the checker believes the function’s answer and never reads its test.
Precisely: limits on guards and assertions
A guard’s type must fit its parameter: entry is string on an Entry parameter gives A type predicate's type must be assignable to its parameter's type. An assertion function called through a const arrow function without a written type gives Assertions require every name in the call target to be declared with an explicit type annotation; declare it with function. Since TypeScript 5.5, the compiler infers a guard for simple arrow functions, so list.filter((x) => x !== undefined) has the type string[] for a (string | undefined)[]. as refuses only types that cannot overlap, such as "5" as number; through unknown it accepts anything.
Experiment
Trust a type guard that tests the wrong thing
entry is Meeting tells the compiler: when isMeeting returns true, entry is a Meeting. The compiler checks only that the function returns a boolean, not that its test matches the promise. entry.kind !== "dayOff" is also true for a reminder, so the reminder is treated as a meeting, and its room is undefined.
Why it matters A type guard is a promise you write, and nothing checks it. When a union gets a new member, read every guard over it again; a guard that tests the kind with === stays right.
Predict the printed lines. Then change line 7 to return entry.kind === "meeting"; and predict again.
type Meeting = { kind: "meeting"; room: string };
type DayOff = { kind: "dayOff"; person: string };
type Reminder = { kind: "reminder"; note: string };
type Entry = Meeting | DayOff | Reminder;
function isMeeting(entry: Entry): entry is Meeting {
return entry.kind !== "dayOff";
}
const today: Entry[] = [
{ kind: "meeting", room: "North" },
{ kind: "reminder", note: "send notes" },
];
for (const entry of today) {
if (isMeeting(entry)) {
console.log("book " + entry.room);
}
}
What to look for
For the meeting, isMeeting’s frame shows entry {kind: "meeting", room: "North"}, and the stepper goes on to line 16. For the reminder, entry shows {kind: "reminder", note: "send notes"}. isMeeting returns true again, and the stepper goes to line 16 once more, for an entry that has no room. The run prints book North, then book undefined. After the edit, the stop after isMeeting for the reminder is the loop line 14, and the run prints only book North.
Expected output
book North
book undefined05 / Callbacks
When does a narrowed type stop applying?
Narrowing lasts until something can change the value. Inside a callback, a function passed somewhere to run later, the compiler cannot know when the callback will run. If a let is assigned anywhere after the callback is created, the callback may see the new value, so inside the callback the compiler uses the let’s declared type. room.toUpperCase() then fails to compile with 'room' is possibly 'undefined', although the check is right above it. As Closures and captured variables showed, the callback reads the variable itself, not a copy.
The fix is to copy the narrowed value into a const inside the check. A const cannot be assigned again, so its narrowed type holds for every callback that uses it.
A const of type number | undefined is narrowed inside callbacks too, and so is a let that is never assigned after the callback is created.
function plan(start: string | undefined): void {
let room = start;
const later: (() => string)[] = [];
if (room !== undefined) {
// a const keeps the narrowed type
const booked = room;
later.push(() => "booked " + booked.toUpperCase());
}
room = undefined;
} A callback sees a let with its declared type when the let is assigned after the callback is created; copy the narrowed value into a const.
Precisely: what the compiler forgets and what it keeps
The rule that keeps narrowing in a callback when no assignment follows was added in TypeScript 5.4. It does not apply to a let at the top level of a file with no import or export, because such a name is shared with other files; the stepper runs such a file, so the experiment puts room inside a function. The compiler is less careful with properties: after if (slot.room !== undefined), a call such as clear(slot) can set slot.room to undefined, and slot.room.toUpperCase() still compiles, then throws a TypeError. (() => string)[] is the type of an array of functions that take nothing and return a string.
Experiment
Keep a narrowed value for a callback
The check room !== undefined is true when line 6 runs, but the arrow function runs later, after line 8 has set room to undefined. The compiler sees an assignment to room after the arrow function is created, so inside the function it uses the declared type, string | undefined. booked is a const that holds the narrowed value, so it is a string for as long as it exists.
Why it matters When a callback needs a narrowed let, copy the value into a const inside the check, and use the const in the callback.
Predict what plan prints. Then change booked.toUpperCase() on line 6 to room.toUpperCase() and predict whether the program compiles.
function plan(start: string | undefined): void {
let room = start;
const later: (() => string)[] = [];
if (room !== undefined) {
const booked = room;
later.push(() => "booked " + booked.toUpperCase());
}
room = undefined;
for (const task of later) {
console.log(task());
}
}
plan("North");
What to look for
Step past line 6: later shows [arrow function ()], a list that holds the arrow function, which has not run yet. Step past line 8: room shows undefined, and booked is no longer listed, because its block has ended. When task() runs, the stepper enters a frame named arrow function (line 6), which lists booked "North". The run prints booked NORTH. After the edit, nothing runs, and the compiler reports Line 6: 'room' is possibly 'undefined'.
Expected output
booked NORTH06 / In production
What goes wrong with narrowing in real programs?
Each bug below passes the compiler, because the compiler believed a check that was wrong or too weak.
A guard for JSON that checks too little. A value from a server has whatever shape the text had. A guard such as isMeeting(x: any): x is Meeting that tests only x.kind lets {"kind": "meeting", "room": 12} through, and room.toUpperCase() then throws. Give the parameter the type unknown, so the compiler asks for every check, and test each field you will use.
Truthiness that drops 0 or "". if (!minutesBefore) treats a reminder at 0 minutes like a missing one, and if (!note) treats an empty note like no note. The experiment shows it.
Narrowing silenced in a callback. When the compiler says a let is possibly undefined inside a callback, writing room! or room as string makes the error go away; x! tells the compiler that x is not null or undefined and, like as, checks nothing. The callback then throws when it runs after the change. Copy the value into a const instead.
A new member behind a default. A default that returns a value, or a switch in a function that returns nothing, accepts a new kind without any error. Put a never check in the default.
function isMeeting(x: unknown): x is Meeting {
return (
typeof x === "object" &&
x !== null &&
"kind" in x &&
x.kind === "meeting" &&
"room" in x &&
typeof x.room === "string"
);
} A guard on outside data checks every field it promises, a missing value is compared with undefined, a callback uses a const, and a default holds a never check.
Experiment
Send a reminder at 0 minutes through a truthiness check
!minutesBefore is true for every falsy value, and 0 is falsy. The compiler removes undefined from the type after the if, which is right, but it says nothing about the value 0 going the same way, because 0 is a number like any other.
Why it matters Use a truthiness test only when 0 and "" mean the same as missing. Otherwise compare with undefined.
Predict the three lines. Then change line 2 to if (minutesBefore === undefined) { and predict again.
function reminderText(minutesBefore: number | undefined): string {
if (!minutesBefore) {
return "no reminder";
}
return "remind " + minutesBefore + " min before";
}
console.log(reminderText(15));
console.log(reminderText(0));
console.log(reminderText(undefined));
What to look for
For 15, the stop after the if line is line 5. For 0, minutesBefore shows 0, and the next stop is line 3: a reminder at the start time takes the no reminder path. The run prints remind 15 min before, then no reminder twice. After the edit, the call with 0 goes on to line 5, and the second line becomes remind 0 min before.
Expected output
remind 15 min before
no reminder
no reminder07 / Try it
Can you predict which entries pass each check?
Each call entry comes from JSON, and as Entry[] checks nothing. Follow each entry through label.
type Call = { kind: "call"; minutes: number };
type Lunch = { kind: "lunch"; place: string };
type Entry = Call | Lunch;
function label(entry: Entry): string {
if ("minutes" in entry && entry.minutes) {
return "call of " + entry.minutes + " min";
}
if (entry.kind === "call") {
return "call, length not set";
}
return "lunch at " + entry.place;
}
const text = '[{"kind": "call", "minutes": 0}, {"kind": "call"}]';
const day = JSON.parse(text) as Entry[];
day.push({ kind: "lunch", place: "the canteen" });
for (const entry of day) {
console.log(label(entry));
}
What are the three output lines?
Reveal the reasoning
call, length not set, twice, then lunch at the canteen. The first call has minutes 0: "minutes" in entry is true, but 0 is falsy, so the first if fails, and entry.kind === "call" sends it to the second return. The second call has no minutes property, although its type says it must: in is false, and the second return runs again. The lunch fails both checks. By the last line, control flow analysis has removed Call, so entry.place compiles.
Practice tickets
- TS-301 · The ground floor team is sent to reception → Free
- TS-302 · Conference requests show a cost of NaN → Sign in · Pro
- TS-303 · One new sensor message blanks the desk screen → Sign in · Pro
Keep exploring
Primary references
- TypeScript Handbook: Narrowing ↗
- TypeScript Handbook: Using type predicates ↗
- TypeScript 3.7 release notes: Assertion functions ↗
- TypeScript 5.4 release notes: Preserved narrowing in closures following last assignments ↗
- TypeScript 5.5 release notes: Inferred type predicates ↗
- MDN: typeof (typeof null) ↗