A union is only half a tool. string | number lets you accept both and then stops you
using either, and the thing that gets you the rest of the way is narrowing: writing
a check the compiler can read, so that inside the block it knows which branch you are on.
TypeScript does this by following your control flow. You are not annotating anything here — you are writing ordinary JavaScript, and the checker is reading it.
typeof
The simplest guard, and it works on the primitives:
function pad(id: string | number) {
if (typeof id === 'string') {
return id.padStart(8, '0'); // id is string here
}
return id.toFixed(0); // and number here
}Note the second line. After the if returns, the only remaining possibility is
number, so nothing further is needed — TypeScript narrows on the way out of a branch as
well as into one.
The one trap: typeof null === 'object', a JavaScript quirk TypeScript inherits
faithfully. Checking typeof x === 'object' does not exclude null.
Truthiness, and where it misleads
A plain if narrows out null and undefined:
function render(imageUrl: string | null) {
if (imageUrl) {
return imageUrl.toUpperCase(); // string
}
return 'No image';
}Convenient, and wrong whenever an empty string, 0 or false is a
meaningful value:
function label(count: number | null) {
if (count) return `${count} items`;
return 'None'; // also returns 'None' for 0 — probably a bug
}When the distinction matters, compare explicitly:
if (count !== null) { /* 0 gets here too */ }This is why the app's helpers check if (sub.field) in one place and
value !== null in another — the first is a string that is meaningless when empty, the
second is not.
Equality
Comparing two values narrows both of them, and comparing against a literal narrows to it:
function fee(orderType: OrderType, subtotal: number) {
if (orderType === 'DELIVERY' && subtotal > 0) {
return DELIVERY_FEE;
}
return 0;
}Inside that block orderType is 'DELIVERY' exactly. On the union of
five order statuses, a chain of === checks narrows one member at a time.
The in operator
For object unions, in asks whether a property exists:
type Result = { data: Order[] } | { error: string };
function show(result: Result) {
if ('error' in result) {
return result.error; // the error branch
}
return result.data.length; // the data branch
}Useful when the branches have no shared discriminator field. When they do — and you control the type — the discriminated union below is better.
instanceof
For anything with a constructor, including your own error classes. This is the pizza app's Redux error edge, and it is a clean example of three narrowings in four lines:
export function toApiFailure(err: unknown, fallback: string): ApiFailure {
if (err instanceof ApiError) {
return { message: err.message, fieldErrors: err.fieldErrors() };
}
return { message: err instanceof Error ? err.message : fallback, fieldErrors: {} };
}Start with unknown, on which nothing is allowed. The first check proves
ApiError, which is what makes err.fieldErrors() — a method on that class
and no other — legal. The second proves Error, which permits .message.
Neither is available before its check, and that is the point.
The Angular half narrows the same way, over a different set of classes:
static from(error: unknown): ApiError {
if (error instanceof ApiError) return error;
if (error instanceof HttpErrorResponse) {One caveat worth knowing. instanceof is a prototype check, so subclassing built-ins
can break it when compiling to ES5. The app leaves a note where it matters:
// TypeScript compiling to ES5 breaks `instanceof` for subclassed built-ins. This app targets
// ES2022 so it is not strictly needed, but it costs one line and removes a nasty trap.
Object.setPrototypeOf(this, ApiError.prototype);Discriminated unions
The best-behaved pattern in the language. Give every member of a union a common property with a different literal type, and checking that one property narrows the whole object:
export type Outcome = { ok: true } | { ok: false; failure: ApiFailure };The discriminant is ok, and because its type is true in one branch and
false in the other, a single check settles which shape you have:
const result = await done;
if (result.ok) {
toast.success('Saved.');
} else {
showErrors(result.failure); // only reachable here, and guaranteed present
}result.failure in the first branch is a compile error, and in the second branch it
is not optional. Compare that with a single interface carrying failure?: ApiFailure,
where the property is reachable everywhere and guaranteed nowhere.
Any literal type works as a discriminant — the status: 'loading' | 'success' shape
from the previous lesson is the same idea with five branches instead of two. A string field named
kind, type or status is the usual convention.
Exhaustiveness: making the compiler check for you
Here is what discriminated unions really buy. Narrow through every case and the leftover type is
never, which you can assert:
function statusLabel(status: OrderStatus): string {
switch (status) {
case 'PENDING_PAYMENT': return 'Awaiting payment';
case 'PAID': return 'Paid';
case 'PREPARING': return 'In the oven';
case 'COMPLETED': return 'Completed';
case 'CANCELLED': return 'Cancelled';
default: {
const unreachable: never = status;
return unreachable;
}
}
}Today that compiles, because all five cases are handled and status in the
default has type never.
Now add 'REFUNDED' to OrderStatus. The default is
reachable with status: 'REFUNDED', that is not assignable to never, and
the build fails here — pointing at the function that has not been updated:
Type '"REFUNDED"' is not assignable to type 'never'.That is the mechanism that turns "add a status" from a search-and-hope exercise into a task with a list. It is worth doing in every switch over a union that matters, and it costs three lines.
Note that noFallthroughCasesInSwitch — on in both pizza configs — is the
complementary guard, catching a case that forgets its break.
Type predicates: teaching the compiler your own check
Sometimes the check is a function, and by default a boolean return tells TypeScript
nothing:
// This does not compile.
function isProduct(value: unknown): boolean {
return typeof value === 'object' && value !== null && 'sizes' in value;
}
if (isProduct(thing)) {
thing.sizes; // 'thing' is still unknown
// ~~~~~
}Change the return type to a type predicate and the call site narrows:
function isProduct(value: unknown): value is Product {
return typeof value === 'object' && value !== null && 'sizes' in value;
}value is Product means "if this returns true, treat the argument as a
Product". That is a promise you are making — the compiler checks the body is
plausible but cannot verify the logic, so a sloppy predicate is an assertion in disguise. Keep them
short and obviously correct.
This is also the fix for the filter problem from
lesson 5:
const real = urls.filter((u): u is string => u !== null); // string[]TypeScript 5.5 improved this: a simple arrow function whose body is obviously a narrowing check now has its predicate inferred, so the annotation is often unnecessary in newer code. Writing it explicitly still works and is clearer at a glance.
Assertion functions
A variant that throws instead of returning a boolean, narrowing everything after the call:
function assertIsProduct(value: unknown): asserts value is Product {
if (!isProduct(value)) throw new Error('Not a product');
}
assertIsProduct(thing);
thing.sizes; // narrowed from here onHandy at a boundary where a bad value means the operation cannot continue. One quirk: an
assertion function must have an explicit type annotation at the point it is declared — it cannot be
an arrow function assigned to an inferred const.
The built-in guards
A few standard functions are declared as type predicates, so they narrow without you doing
anything. Array.isArray is the one you will reach for:
function count(value: Order | Order[]) {
if (Array.isArray(value)) {
return value.length; // Order[]
}
return 1; // Order
}Its signature is isArray(arg: any): arg is any[] — the same
x is T form you can write yourself.
Optional chaining and nullish coalescing are narrowing tools too, and often the shortest route:
const name = user?.fullName ?? user?.email ?? 'Account';Each ?. short-circuits to undefined when the left side is absent, so the
result type is a union ending in undefined, and ?? then removes it by
supplying a value. The Angular app builds its navbar label exactly this way.
Note that ?? differs from || in precisely the way that matters here: it
falls back only on null and undefined, not on 0 or the empty
string. Reach for it by default, for the same reason the truthiness section warned about
if.
Where narrowing is lost
Two situations where the checker gives up, both of which look like bugs until you know the reason.
Reassignment. Narrowing tracks a variable, and any assignment resets it.
Callbacks. This is the common one:
// This does not compile.
function send(order: Order | null) {
if (!order) return;
setTimeout(() => {
console.log(order.id);
// ~~~~~
// 'order' is possibly 'null'.
}, 1000);
}TypeScript cannot know when the callback runs, and if order were a
let that something else reassigned, the narrowing would no longer hold. With a
const parameter this particular case is safe and the compiler is being conservative —
but the fix is the same and costs nothing:
function send(order: Order | null) {
if (!order) return;
const confirmed = order; // captured, and const
setTimeout(() => console.log(confirmed.id), 1000);
}The same thing bites on class properties, which genuinely can change between the check and the use. A local copy is the standard answer there too.
Narrowing is not validation
A closing distinction, because it is the one people get wrong. Narrowing convinces the compiler. It does not check anything the compiler was not already able to see.
A type predicate is the clearest case: value is Product is a promise you make, and
the body is not verified against it. Write a predicate that checks one field and claims a
twelve-field interface, and every call site will believe you.
So at a real boundary — a response, a stored value, a message from elsewhere — narrowing is a way of organising your checks, not a substitute for writing them. If the data genuinely arrives untrusted, the check has to be exhaustive, and a schema library is the honest tool.
Next
Functions — parameters, overloads, and typing the callbacks that most of this lesson's examples were quietly passing around.