Special types and narrowing Foundation
Narrowing
Turn wide union and unknown types into precise ones with typeof, truthiness, equality, in, instanceof, discriminants, type guards and assertion functions, and learn where narrowing lies.
BY THE END OF THIS LESSON YOU CAN
- Explain the difference between a declared type and a narrowed type
- Narrow unions with typeof, equality, in, instanceof and Array.isArray
- Avoid the truthiness traps with 0, empty strings and NaN
- Follow how control-flow analysis narrows through returns, assignments and closures
- Write type guards and assertion functions, and test them
A value that could be two things
Your bank's old mobile app sends transfer amounts as numbers. The new web app sends them as strings, because they come straight from a form field. Your handler has to accept both, so you give the parameter a union type and try to format it:
function formatAmount(amount: number | string): string {
return "₦" + amount.toFixed(2);
}
npx tsc --noEmit printsamount.ts:2:23 - error TS2339: Property 'toFixed' does not exist on type 'string | number'.
Property 'toFixed' does not exist on type 'string'.
2 return "₦" + amount.toFixed(2);
~~~~~~~
Found 1 error in amount.ts:2The compiler is right. toFixed is a number method, and amount might be a string. You cannot tell it "it is probably a number". You have to check, in plain JavaScript, and TypeScript follows your check:
function formatAmount(amount: number | string): string {
if (typeof amount === "string") {
const parsed = Number(amount);
return "₦" + parsed.toFixed(2);
}
return "₦" + amount.toFixed(2);
}
console.log(formatAmount(1500));
console.log(formatAmount("2500.5"));
npx tsx amount.ts and of the browser terminal₦1500.00 ₦2500.50
Every variable has a declared type: the type written in its annotation, here number | string. At each point in the code, TypeScript also tracks a narrowed type: what the value can still be, given the checks that ran before that point. Inside the if, amount is a string. After the if returns, only number is left. Turning a wide type into a more precise one this way is called narrowing, and the checks that do it are called type guards.
You met narrowing briefly in Interfaces, unions and literal types and used unknown in Special types. This lesson covers every way to narrow, the traps in each, and how to write your own.
typeof
The JavaScript typeof operator returns one of eight strings: "string", "number", "bigint", "boolean", "symbol", "undefined", "object" and "function". TypeScript knows which types go with each string, so comparing typeof x with one of them narrows x:
function describe(value: string | number | boolean | (() => string)): string {
if (typeof value === "string") return `text of length ${value.length}`;
if (typeof value === "number") return `number ${value.toFixed(1)}`;
if (typeof value === "function") return `function returning ${value()}`;
return `boolean ${value ? "yes" : "no"}`;
}
console.log(describe("Ada"));
console.log(describe(42));
console.log(describe(() => "PAID"));
console.log(describe(false));
npx tsx typeof.ts and of the browser terminaltext of length 3 number 42.0 function returning PAID boolean no
After three checks and three returns, only boolean is left on the last line, so TypeScript lets you use value as a boolean without another check.
The typeof null trap
One JavaScript mistake from 1995 is still with us: typeof null is "object". So typeof x === "object" does not rule out null, and TypeScript knows it:
function countTags(tags: string | string[] | null): number {
if (typeof tags === "object") {
return tags.length;
}
return 1;
}
npx tsc --noEmit printstags.ts:3:12 - error TS18047: 'tags' is possibly 'null'.
3 return tags.length;
~~~~
Found 1 error in tags.ts:3Inside the if, tags is string[] | null, not string[]. Rule out null as well, or use a check that is more precise than typeof:
function countTags(tags: string | string[] | null): number {
if (tags === null) return 0;
if (Array.isArray(tags)) return tags.length;
return 1;
}
console.log(countTags(null), countTags("urgent"), countTags(["urgent", "vip", "lagos"]));
npx tsx tags.ts and of the browser terminal0 1 3
Array.isArray narrows too. TypeScript's declaration for it is isArray(arg: any): arg is any[], a type guard exactly like the ones you will write yourself later in this lesson.
Truthiness narrowing and its traps
An if (value) check runs its branch only when the value is truthy: anything except the eight falsy values false, 0, -0, 0n, NaN, "", null and undefined. TypeScript uses this to remove null and undefined from a type:
function greeting(nickname: string | undefined): string {
if (nickname) {
return `Welcome back, ${nickname.toUpperCase()}`;
}
return "Welcome back";
}
console.log(greeting("Ada"));
console.log(greeting(undefined));
console.log(greeting(""));
npx tsx nickname.ts and of the browser terminalWelcome back, ADA Welcome back Welcome back
That is fine for a nickname, where an empty string really means "no nickname". Now the same habit on money:
function balanceLine(balance: number | null): string {
if (balance) {
return `Balance: ₦${balance.toLocaleString("en-NG")}`;
}
return "Balance not available";
}
console.log(balanceLine(125000));
console.log(balanceLine(null));
console.log(balanceLine(0));
npx tsx balance.ts and of the browser terminalBalance: ₦125,000 Balance not available Balance not available
This compiles, and it is wrong. A customer with exactly ₦0 is told their balance is "not available", because 0 is falsy. TypeScript narrowed correctly: inside the if, balance is a number. But in the else path, balance is still number | null. The compiler never claimed the else branch meant null; the code's author assumed it.
The fix is to say what you mean. When you want "not null or undefined", compare with null:
function balanceLine(balance: number | null): string {
if (balance !== null) {
return `Balance: ₦${balance.toLocaleString("en-NG")}`;
}
return "Balance not available";
}
console.log(balanceLine(0));
const limit: number | undefined = 0;
console.log(limit || 50000, limit ?? 50000);
npx tsx balance.ts and of the browser terminalBalance: ₦0 50000 0
The same trap hides in ||. limit || 50000 replaces every falsy value, so a daily limit of 0 (an account that may not transfer anything) silently becomes ₦50,000. ??, the nullish coalescing operator, only replaces null and undefined.
TRUTHINESS ON NUMBERS AND STRINGS
Useif (value) only when every falsy value really means "missing": objects, arrays, and strings where "" is empty. For numbers, amounts, counts, ids and flags, compare with null or undefined explicitly, and default with ??.Equality narrowing
Comparing with ===, !==, == or != narrows both sides. Comparing with a literal keeps only the matching member; comparing two variables keeps only the types they could share:
type Channel = "sms" | "email" | "push";
function send(channel: Channel, to: string | null, fallback: string | number): string {
if (to == null) return `${channel}: no recipient`;
if (channel === "sms") return `sms to ${to.replace(/^0/, "+234")}`;
if (to === fallback) return `${channel} to ${fallback.toLowerCase()} (same as fallback)`;
return `${channel} to ${to}`;
}
console.log(send("sms", "08031234567", 0));
console.log(send("email", "ADA@EXAMPLE.COM", "ADA@EXAMPLE.COM"));
console.log(send("push", null, 0));
npx tsx equality.ts and of the browser terminalsms to +2348031234567 email to ada@example.com (same as fallback) push: no recipient
to == nullwith two equals signs is the one place where loose equality is useful: it is true for bothnullandundefined, and TypeScript removes both from the type.- After
channel === "sms"returns,channelis"email" | "push". to === fallbackcan only be true when both are strings, becausetois astringby then. So inside that branchfallbackis narrowed tostringtoo, and.toLowerCase()is allowed.
A switch is a series of === checks, and narrows in exactly the same way in each case.
in and instanceof
The in operator
"iban" in account is true when the object has a property called iban. When the union members differ in which properties they have, in picks the right member:
interface LocalAccount {
accountNumber: string;
bankCode: string;
}
interface ForeignAccount {
iban: string;
swift: string;
}
function payee(account: LocalAccount | ForeignAccount): string {
if ("iban" in account) {
return `IBAN ${account.iban} via ${account.swift}`;
}
return `NUBAN ${account.accountNumber} at bank ${account.bankCode}`;
}
console.log(payee({ accountNumber: "0123456789", bankCode: "058" }));
console.log(payee({ iban: "GB29NWBK60161331926819", swift: "NWBKGB2L" }));
npx tsx in.ts and of the browser terminalNUBAN 0123456789 at bank 058 IBAN GB29NWBK60161331926819 via NWBKGB2L
in also works on objects whose type does not mention the property at all. After typeof value === "object" && value !== null && "email" in value, TypeScript knows value has an email property of type unknown. That is how TypeScript and JavaScript together checks request bodies one field at a time.
instanceof
x instanceof C checks whether C.prototype is in the value's prototype chain. It narrows to the class type. This is the everyday way to tell errors apart:
class InsufficientFundsError extends Error {
constructor(readonly shortBy: number) {
super(`short by ₦${shortBy}`);
}
}
function explain(error: unknown): string {
if (error instanceof InsufficientFundsError) return `Top up ₦${error.shortBy} and try again`;
if (error instanceof Error) return `Transfer failed: ${error.message}`;
if (error instanceof Date) return `Retry after ${error.toISOString().slice(0, 10)}`;
return "Transfer failed";
}
console.log(explain(new InsufficientFundsError(2500)));
console.log(explain(new TypeError("amount must be a number")));
console.log(explain(new Date("2026-10-01T00:00:00Z")));
console.log(explain("timeout"));
npx tsx instanceof.ts and of the browser terminalTop up ₦2500 and try again Transfer failed: amount must be a number Retry after 2026-10-01 Transfer failed
Order matters: InsufficientFundsError is also an Error, so the more specific check goes first. Two limits to remember: instanceof only works with classes, never with interfaces or type aliases, because those do not exist at runtime; and an object that came out of JSON.parse is never an instance of your class, even when it has all the right properties.
Discriminated unions
The most reliable way to narrow objects is a discriminant: one property that every member of the union has, with a different literal value in each. A switch on it picks the member, and TypeScript checks every property access in every case:
export type Payment =
| { kind: "card"; amount: number; last4: string }
| { kind: "transfer"; amount: number; bankCode: string; reference: string }
| { kind: "wallet"; amount: number; phone: string };
export function receipt(payment: Payment): string {
switch (payment.kind) {
case "card":
return `₦${payment.amount} charged to card ending ${payment.last4}`;
case "transfer":
return `₦${payment.amount} sent to bank ${payment.bankCode}, ref ${payment.reference}`;
case "wallet":
return `₦${payment.amount} debited from wallet ${payment.phone}`;
}
}
console.log(receipt({ kind: "card", amount: 5000, last4: "4242" }));
console.log(receipt({ kind: "wallet", amount: 1200, phone: "08031234567" }));
npx tsx payment.ts and of the browser terminal₦5000 charged to card ending 4242 ₦1200 debited from wallet 08031234567
Notice the function has no final return, and tsc does not complain. Because the switch covers all three kinds, TypeScript knows the end of the function cannot be reached. Add a fourth kind, "ussd", without a case, and you get Function lacks ending return statement. For a clearer error, end with a default that assigns to never, the exhaustiveness check from Basic types.
The discriminant also works after destructuring, as long as the variable you narrow is a const (or a parameter that is never reassigned):
import type { Payment } from "./payment.js";
function fee(payment: Payment): number {
const { kind, amount } = payment;
if (kind === "transfer") {
return amount > 5000 ? 25 : 10;
}
return kind === "card" ? Math.round(amount * 0.015) : 0;
}
console.log(fee({ kind: "transfer", amount: 20000, bankCode: "058", reference: "RENT" }));
console.log(fee({ kind: "card", amount: 5000, last4: "4242" }));
npx tsx destructure.ts and of the browser terminal25 75
Control-flow analysis
TypeScript does not narrow by looking at one if at a time. It follows every path through the function: returns, throws, breaks, assignments and loops. This is called control-flow analysis. Four rules explain almost everything you will see.
1. Early exits narrow the rest of the block
A return or throw inside an if means the code after it only runs when the check failed. This is why guard clauses work so well in TypeScript:
interface Account {
id: string;
balance: number;
frozenReason?: string;
}
function withdraw(account: Account | undefined, amount: number): number {
if (account === undefined) throw new Error("account not found");
if (account.frozenReason !== undefined) throw new Error(`frozen: ${account.frozenReason}`);
if (amount > account.balance) throw new Error("insufficient funds");
return account.balance - amount;
}
console.log(withdraw({ id: "ACC-1", balance: 10000 }, 2500));
for (const account of [undefined, { id: "ACC-2", balance: 100, frozenReason: "KYC pending" }]) {
try {
withdraw(account, 50);
} catch (error) {
console.log(String(error));
}
}
npx tsx guard-clauses.ts and of the browser terminal7500 Error: account not found Error: frozen: KYC pending
2. Assignment narrows
Assigning a value narrows a variable to the type of what you assigned, within its declared type:
let reference: string | number = "TX-001";
console.log(reference.toLowerCase());
reference = 1042;
console.log(reference.toFixed(0).padStart(6, "0"));
npx tsx assign.ts and of the browser terminaltx-001 001042
3. A condition stored in a const still narrows
You can give a check a name, and the narrowing survives, as long as both the condition and the value are const (or parameters that are never reassigned):
function label(value: string | number): string {
const isText = typeof value === "string";
return isText ? value.toUpperCase() : value.toFixed(2);
}
console.log(label("pending"), label(99.5));
npx tsx aliased.ts and of the browser terminalPENDING 99.50
4. Narrowing does not survive everything
Narrowing is a promise about this point in the code. Two situations break it. First, a callback that runs later, on a variable that is assigned again after the callback is created:
function scheduleReminder(email: string | undefined): void {
if (email === undefined) return;
setTimeout(() => console.log(`remind ${email.toLowerCase()}`), 1000);
email = undefined;
}
npx tsc --noEmit printsclosure.ts:3:42 - error TS18048: 'email' is possibly 'undefined'.
3 setTimeout(() => console.log(`remind ${email.toLowerCase()}`), 1000);
~~~~~
Found 1 error in closure.ts:3When the timer fires, email really is undefined, and the compiler caught it. Remove the last line, or copy the value into a const before the callback, and it compiles. The second situation goes the other way: the compiler keeps a narrowing that is no longer true. It assumes a function call does not change the properties it has already checked:
interface Order {
total: number;
discount?: number;
}
function removeExpiredDiscount(order: Order): void {
delete order.discount;
}
const order: Order = { total: 20000, discount: 2000 };
if (order.discount !== undefined) {
removeExpiredDiscount(order);
try {
console.log(order.discount.toFixed(2));
} catch (error) {
console.log(String(error));
}
}
npx tsx stale.ts and of the browser terminalTypeError: Cannot read properties of undefined (reading 'toFixed')
This compiles and crashes. Checking every property again after every call would make almost all real code fail to compile, so TypeScript makes a practical choice and trusts you. The defence is a habit: read the property into a const (const discount = order.discount), check that, and use it. A const cannot change behind your back.
Custom type guards: value is T
Sometimes the check you need is longer than one operator, or you need it in many places. Put it in a function whose return type is a type predicate, parameterName is Type. When the function returns true, TypeScript narrows the argument to Type; when it returns false, it removes Type from the argument's type:
The examples in this section share the Payment union from before, kept in its own file:
export type Payment =
| { kind: "card"; amount: number; last4: string }
| { kind: "transfer"; amount: number; bankCode: string; reference: string }
| { kind: "wallet"; amount: number; phone: string };
import type { Payment } from "./types.js";
type CardPayment = Extract<Payment, { kind: "card" }>;
function isCardPayment(payment: Payment): payment is CardPayment {
return payment.kind === "card";
}
const payments: Payment[] = [
{ kind: "card", amount: 5000, last4: "4242" },
{ kind: "wallet", amount: 1200, phone: "08031234567" },
{ kind: "card", amount: 800, last4: "1881" },
];
const cards = payments.filter(isCardPayment);
console.log(cards.map((card) => card.last4));
const first = payments[1];
if (first !== undefined && !isCardPayment(first)) {
console.log(first.kind);
}
npx tsx guards.ts and of the browser terminal[ '4242', '1881' ] wallet
Extract<Payment, { kind: "card" }> picks the card member out of the union; you will meet it properly in Advanced and utility types. Passing the guard to filter is the classic use: filter has an overload that takes a type predicate, so cards is CardPayment[], not Payment[], and card.last4 is allowed. In the else direction, !isCardPayment(first) leaves "transfer" | "wallet" payments.
Inferred type predicates
Since TypeScript 5.5, a short arrow function that only returns a check is inferred as a type guard, so you often do not need to write the predicate:
const amounts = [1500, undefined, 700, undefined, 2500];
const present = amounts.filter((amount) => amount !== undefined);
const total = present.reduce((sum, amount) => sum + amount, 0);
console.log(present.length, total);
npx tsx inferred.ts and of the browser terminal3 4700
Before 5.5, present was (number | undefined)[] and sum + amount was an error. Inference only works when the function has no written return type and its body is a check TypeScript can read both ways; write the predicate yourself when the check is longer.
A guard can lie
TypeScript does not check that the body of a guard matches its predicate. It believes the true, and it also believes the false. A guard that returns false for some values that are the type makes the else branch lie:
function isBigAmount(amount: number | string): amount is number {
return typeof amount === "number" && amount >= 1_000_000;
}
function audit(amount: number | string): string {
if (isBigAmount(amount)) return `review ₦${amount}`;
try {
return `auto-approve ${amount.toUpperCase()}`;
} catch (error) {
return String(error);
}
}
console.log(audit(2_500_000));
console.log(audit("450000"));
console.log(audit(450000));
npx tsx lying-guard.ts and of the browser terminalreview ₦2500000 auto-approve 450000 TypeError: amount.toUpperCase is not a function
In the else path TypeScript removed number, so it let amount.toUpperCase() through. But ₦450,000 as a number also ends up there. A predicate must mean "is this type", exactly. Checks about the value ("is big", "is active") belong in a plain boolean function after narrowing.
Assertion functions: asserts value is T
A type guard returns a boolean and leaves the if to you. An assertion function throws when the check fails, and when it returns normally, the narrowing holds for the rest of the scope. There are two forms: asserts condition narrows by whatever expression you pass, and asserts value is T narrows one argument:
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function assertIsString(value: unknown, field: string): asserts value is string {
if (typeof value !== "string") throw new TypeError(`${field} must be a string`);
}
function openAccount(name: unknown, openingDeposit: number | null): string {
assertIsString(name, "name");
assert(openingDeposit !== null, "opening deposit is required");
assert(openingDeposit >= 1000, "opening deposit must be at least ₦1000");
return `${name.trim()}: ₦${openingDeposit.toFixed(2)}`;
}
console.log(openAccount(" Ada Obi ", 5000));
for (const [name, deposit] of [[42, 5000], ["Grace", null], ["Grace", 500]] as const) {
try {
openAccount(name, deposit);
} catch (error) {
console.log(String(error));
}
}
npx tsx assert.ts and of the browser terminalAda Obi: ₦5000.00 TypeError: name must be a string Error: opening deposit is required Error: opening deposit must be at least ₦1000
After assert(openingDeposit !== null, …), openingDeposit is a number on every following line, exactly as if you had written if (openingDeposit === null) throw …. Node.js ships the same idea as assert from node:assert, which is typed asserts value too.
One rule surprises everyone the first time. An assertion function must be called through a name whose type is written out. An arrow function stored in a const without an annotation does not count:
const assertPositive = (amount: number): asserts amount => {
if (amount <= 0) throw new RangeError("amount must be positive");
};
assertPositive(500);
npx tsc --noEmit printsarrow-assert.ts:5:1 - error TS2775: Assertions require every name in the call target to be declared with an explicit type annotation.
5 assertPositive(500);
~~~~~~~~~~~~~~
arrow-assert.ts:1:7 - 'assertPositive' needs an explicit type annotation.
1 const assertPositive = (amount: number): asserts amount => {
~~~~~~~~~~~~~~
Found 1 error in arrow-assert.ts:5Control-flow analysis runs before full type inference, so it needs to know from the declaration alone that a call can narrow. Use a function declaration, as above, or annotate the const: const assertPositive: (amount: number) => asserts amount = ….
Guards and assertion functions are also how you check data from outside your program, where the value starts as unknown. That boundary work, and why a guard must check every field, is covered in TypeScript and JavaScript together.
Build: a payment router
REASON IT OUT
Before you write the router
A payments endpoint receives already-parsed payments, some from the old app (amount as a number) and some from the web app (amount as a string). Each must be routed to the card processor, the bank transfer service or the wallet service. Before reading the code, think:
- Which of the checks in this lesson fits each decision: which processor, which amount format, which error?
- What amounts must be refused? Think about
"abc","0",-500,NaN, and a string with spaces. - Where would
if (amount)be a bug? - A processor can fail with a known error class, a generic
Error, or something that is not an error at all. How do you report each?
Show the reasoning
The processor is chosen by the kind discriminant, with a switch so a new kind cannot be forgotten. The amount format is a typeof check. The amount must be converted and then checked with Number.isFinite and > 0: Number("abc") is NaN, which is still of type number, so no type check catches it. if (amount) would treat 0 as "missing" instead of "invalid", and would accept "0", a non-empty string. Errors are unknown in catch, so they are narrowed with instanceof, most specific class first, with a fallback for anything else.
type Amount = number | string;
type Payment =
| { kind: "card"; amount: Amount; last4: string }
| { kind: "transfer"; amount: Amount; bankCode: string }
| { kind: "wallet"; amount: Amount; phone: string };
class ProcessorDownError extends Error {
constructor(readonly processor: string) {
super(`${processor} is unavailable`);
}
}
function toKobo(amount: Amount): number {
const naira = typeof amount === "string" ? Number(amount.trim()) : amount;
if (!Number.isFinite(naira) || naira <= 0) {
throw new RangeError(`invalid amount: ${JSON.stringify(amount)}`);
}
return Math.round(naira * 100);
}
function charge(payment: Payment, kobo: number): string {
switch (payment.kind) {
case "card":
return `card *${payment.last4} charged ${kobo} kobo`;
case "transfer":
if (payment.bankCode === "999") throw new ProcessorDownError("NIP gateway");
return `transfer to bank ${payment.bankCode}: ${kobo} kobo`;
case "wallet":
if (!/^0\d{10}$/.test(payment.phone)) throw "wallet rejected the phone number";
return `wallet ${payment.phone} debited ${kobo} kobo`;
default: {
const unhandled: never = payment;
throw new Error(`unknown payment ${JSON.stringify(unhandled)}`);
}
}
}
export function route(payment: Payment): string {
try {
return "OK " + charge(payment, toKobo(payment.amount));
} catch (error) {
if (error instanceof ProcessorDownError) return `RETRY ${error.processor}`;
if (error instanceof RangeError) return `FAIL ${error.message}`;
if (error instanceof Error) return `FAIL unexpected: ${error.message}`;
return `FAIL ${String(error)}`;
}
}
const requests: Payment[] = [
{ kind: "card", amount: 5000, last4: "4242" },
{ kind: "transfer", amount: " 12500.50 ", bankCode: "058" },
{ kind: "wallet", amount: "1200", phone: "08031234567" },
{ kind: "card", amount: "abc", last4: "1881" },
{ kind: "card", amount: 0, last4: "1881" },
{ kind: "transfer", amount: 700, bankCode: "999" },
{ kind: "wallet", amount: 300, phone: "12345" },
];
for (const payment of requests) console.log(route(payment));
npx tsx router.ts and of the browser terminalOK card *4242 charged 500000 kobo OK transfer to bank 058: 1250050 kobo OK wallet 08031234567 debited 120000 kobo FAIL invalid amount: "abc" FAIL invalid amount: 0 RETRY NIP gateway FAIL wallet rejected the phone number
Every narrowing form from this lesson is here. typeof picks the amount format, Number.isFinite catches the NaN that no type can, the discriminant chooses the processor, never guards against a forgotten kind, and instanceof sorts the errors. Converting to kobo (hundredths of a naira) as a whole number keeps money out of floating-point trouble from here on.
Testing narrowing code
The compiler proves that each branch uses the value correctly. It cannot prove that your checks send each value to the right branch. That is what tests are for, and guards are the easiest code in the world to test: a table of values and the answer you expect for each.
function isNuban(value: unknown): value is string {
return typeof value === "string" && /^\d{10}$/.test(value);
}
const cases: [unknown, boolean][] = [
["0123456789", true],
["012345678", false],
["01234567890", false],
["01234 56789", false],
[1234567890, false],
[null, false],
[undefined, false],
[["0123456789"], false],
];
let failures = 0;
for (const [input, expected] of cases) {
const actual = isNuban(input);
if (actual !== expected) failures++;
console.log(`${actual === expected ? "PASS" : "FAIL"} ${JSON.stringify(input)} -> ${actual}`);
}
console.log(`${failures} failures`);
npx tsx guard.test.ts and of the browser terminalPASS "0123456789" -> true PASS "012345678" -> false PASS "01234567890" -> false PASS "01234 56789" -> false PASS 1234567890 -> false PASS null -> false PASS undefined -> false PASS ["0123456789"] -> false 0 failures
For a guard, always include the values that are almost right (nine digits, eleven digits, a number instead of a string) and the values typeof gets wrong (null, arrays). In a real project the same table goes into a Vitest it.each, which you will meet in the testing lesson.
Narrowing in production code
- Prefer discriminants to property sniffing.
"iban" in accountworks, but akindproperty is explicit, survives refactoring, and gives you exhaustiveness checks. - Narrow once, near the edge. A function that receives
string | numbershould turn it into one type early (liketoKobo) so the rest of the code has nothing to narrow. - Copy before callbacks.
const email = user.email, then useemailin the callback. It removes both closure problems and stale property narrowing. - Keep predicates exact. A predicate means "is this type", nothing else. Keep guards short and test them with a table.
- Never narrow with
as.payment as CardPaymentcompiles without any check. The next lesson explains exactly whatasdoes, and when it is acceptable.
Practice
TRY IT YOURSELF
Fix the zero-quantity bug
This inventory function says "out of stock" for beans, which is right (0 left), and also for garri, which the shop does not sell at all. Rewrite it so a missing item returns "unknown item", 0 returns "out of stock", and anything else returns "N in stock".
const stock: Record<string, number | undefined> = { rice: 12, beans: 0 };
function stockLine(item: string): string {
const quantity = stock[item];
if (!quantity) return "out of stock";
return `${quantity} in stock`;
}
console.log(stockLine("rice"), "|", stockLine("beans"), "|", stockLine("garri"));
npx tsx stock.ts and of the browser terminal12 in stock | out of stock | out of stock
Show a solution
const stock: Record<string, number | undefined> = { rice: 12, beans: 0 };
function stockLine(item: string): string {
const quantity = stock[item];
if (quantity === undefined) return "unknown item";
if (quantity === 0) return "out of stock";
return `${quantity} in stock`;
}
console.log(stockLine("rice"), "|", stockLine("beans"), "|", stockLine("garri"));
npx tsx stock.ts and of the browser terminal12 in stock | out of stock | unknown item
!quantity lumped together two different facts: "not in the list" (undefined) and "none left" (0). Compare with each value explicitly and each case gets its own branch.
TRY IT YOURSELF
A guard for a union member
Bookings are { status: "confirmed"; seat: string } or { status: "waitlisted"; position: number }. Write a type guard isConfirmed and use it with filter to print the seats of the confirmed bookings.
Show a solution
type Confirmed = { status: "confirmed"; seat: string };
type Waitlisted = { status: "waitlisted"; position: number };
type Booking = Confirmed | Waitlisted;
function isConfirmed(booking: Booking): booking is Confirmed {
return booking.status === "confirmed";
}
const bookings: Booking[] = [
{ status: "confirmed", seat: "12A" },
{ status: "waitlisted", position: 3 },
{ status: "confirmed", seat: "4C" },
];
console.log(bookings.filter(isConfirmed).map((booking) => booking.seat));
npx tsx bookings.ts and of the browser terminal[ '12A', '4C' ]
The predicate is exact: it returns true for every confirmed booking and only for them, so both the true and the false narrowing are honest. With TypeScript 5.5 or later, bookings.filter((b) => b.status === "confirmed") infers the same predicate.
TRY IT YOURSELF
An assertion for account ids
Write assertAccountId(value: unknown): asserts value is string that accepts strings like "ACC-00042" (ACC- and five digits) and throws a TypeError otherwise. Call it on three values and print either the id in lower case or the error.
Show a solution
function assertAccountId(value: unknown): asserts value is string {
if (typeof value !== "string" || !/^ACC-\d{5}$/.test(value)) {
throw new TypeError(`not an account id: ${JSON.stringify(value)}`);
}
}
for (const input of ["ACC-00042", "ACC-42", 42]) {
try {
assertAccountId(input);
console.log(input.toLowerCase());
} catch (error) {
console.log(String(error));
}
}
npx tsx account-id.ts and of the browser terminalacc-00042 TypeError: not an account id: "ACC-42" TypeError: not an account id: 42
After the call, input is a string, so toLowerCase compiles. JSON.stringify in the message shows the difference between the string "42" and the number 42.
Recap
- A variable has a declared type and, at each point in the code, a narrowed type. Checks written in plain JavaScript narrow it.
typeofnarrows primitives and functions, buttypeof nullis"object".Array.isArray,inandinstanceofnarrow objects;instanceofneeds a class.- Truthiness removes
nullandundefined, but also sends0,""andNaNto the other branch. Compare explicitly for numbers and use??for defaults. - Equality narrows both sides;
== nullcoversnullandundefined. Aswitchon a discriminant is the most reliable way to narrow objects. - Control-flow analysis follows returns, throws and assignments. Narrowing can be lost in callbacks, and can go stale on properties after a function call.
value is Tguards andasserts value is Tfunctions package checks for reuse. The compiler trusts them in both directions, so keep them exact and test them.
Next: Type assertions, the one tool that changes a type without any check at all, and when it is still the right choice.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.