Narrowing in Depth
Type guards, discriminated unions, and exhaustiveness checks that turn a new case into a compile error instead of a silent fall-through.
Type guards, discriminated unions, and exhaustiveness checks that turn a new case into a compile error instead of a silent fall-through.
A function checks that a value is a valid photo, returns true,
and the caller still gets an error for using it as a photo. The
check ran. It proved the thing. TypeScript has no idea, because
a function returning boolean tells the checker nothing about
what that boolean means.
The foundations course covered narrowing with built-in checks.
This lesson is about extending it: teaching the checker what
your own functions prove, keeping a switch exhaustive as the
code changes, and recognising the places narrowing silently
stops applying.
A quick recap, because everything here builds on it:
typeof value === "string" // primitives
value === null value == null // absence
Array.isArray(value)
value instanceof Error // classes
"size" in photo // property presence
photo.kind === "raw" // a literal discriminantControl flow narrows too, which is worth stating explicitly:
function process(photo: Photo | null): string {
if (photo === null) {
throw new Error("no photo");
}
return photo.name; // Photo - the throw removed the other case
}A throw or a return in one branch eliminates that
possibility from everything after it. That is why early returns
read so well here: each one permanently removes a case.
Assignment narrows as well:
let value: string | number = getValue();
value = "text";
value.toUpperCase(); // string, from the assignmentBad — a check returning
boolean.
function isPhoto(value: unknown): boolean {
return typeof value === "object" && value !== null && "name" in value;
}
if (isPhoto(input)) {
input.name;
// ~~~~ 'input' is of type 'unknown'
}Good — a check that says what it proves.
function isPhoto(value: unknown): value is Photo {
return typeof value === "object" && value !== null && "name" in value;
}
if (isPhoto(input)) {
input.name; // Photo
}value is Photo is a type predicate. The function still
returns a boolean at runtime; the annotation tells the checker
what true means.
The first version forces the caller to assert afterwards —
(input as Photo).name — which discards the check they just
performed and would compile identically if the check were
removed. So the assertion, not the check, is what the type
system is relying on.
The important caveat: a predicate is a promise the checker does not verify. Write one whose body does not actually establish the type and you have a hole with a confident-looking signature. Keep the body a direct check of exactly what the predicate claims.
value is T narrows the true branch only. In the false
branch the type is unchanged, which is wrong when the union has
two members:
function isString(value: string | number): value is string { ... }
if (isString(value)) {
value; // string
} else {
value; // still string | number
}asserts and the newer is behaviour fix this. The current
tool is TypeIs-style narrowing, available by writing the
predicate so both sides follow — in practice, for a union you
own, prefer a discriminant check inline, and reserve predicates
for validating unknown.
The other form is an assertion function:
function assertPhoto(value: unknown): asserts value is Photo {
if (!isPhoto(value)) {
throw new Error("not a photo");
}
}
assertPhoto(input);
input.name; // Photo, for the rest of the scopeasserts value is Photo means "if this returns, the value is a
Photo". No if needed at the call site, and the narrowing
applies to everything after.
One constraint catches people: an assertion function must be
called on a value with an explicit type annotation, and it
cannot be an arrow function assigned to an inferred const.
Declare it with function.
The foundations course showed a switch over a discriminated
union. The version that keeps working as the union grows:
type Upload =
| { status: "pending" }
| { status: "done"; url: string }
| { status: "failed"; error: string };
function describe(upload: Upload): string {
switch (upload.status) {
case "pending":
return "Waiting";
case "done":
return upload.url;
case "failed":
return upload.error;
default:
return assertNever(upload);
}
}
function assertNever(value: never): never {
throw new Error(`Unhandled: ${JSON.stringify(value)}`);
}When every case is handled, upload in the default branch is
never and the call compiles. Add a fourth state and the
remaining member is not assignable to never, so you get an
error naming exactly what you forgot.
assertNever written once and imported everywhere is the
practical form. It costs one file and turns "find every switch
on this union" into a compile error listing them.
The same technique works for if/else if chains and for
mapped lookups:
const LABELS: Record<Upload["status"], string> = {
pending: "Waiting",
done: "Ready",
failed: "Failed",
// adding a status makes this object incomplete -> an error
};Record<Union, T> requires every member as a key. That is often
tidier than a switch when each case maps to a value rather than
to logic.
Four places it silently does not apply.
Inside a callback
It has no idea when the callback runs, and the value could be reassigned before it does.
After an await or any call
For a mutable value, anything could have run in between.
Through a property of a mutable object
The checker assumes the property did not change. A function call between two uses can make that false.
A let reassigned in a loop
What you proved on one pass says nothing about the next.
The first one, concretely:
if (photo !== null) {
setTimeout(() => photo.name, 0);
// ~~~~~ possibly null
}Capturing it in a const fixes it, because a const cannot
change:
if (photo !== null) {
const current = photo;
setTimeout(() => current.name, 0);
}And the third one, which is the easiest to miss because it looks like ordinary sequential code:
if (state.photo !== null) {
process(state.photo); // narrowed here
refresh(); // could set state.photo = null
process(state.photo); // still narrowed - and possibly wrong
}Destructuring first removes the doubt:
const { photo } = state;
if (photo !== null) { ... }The general fix is the same each time: capture what you
narrowed into a const.
The pattern for data you did not create:
function isPhoto(value: unknown): value is Photo {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.name === "string" &&
typeof candidate.size === "number"
);
}Three steps: rule out non-objects and null, treat it as a
record of unknowns, then check each field. The single as is
contained and does not claim anything about the fields — every
one is checked individually.
This is honest and it is tedious, and for anything with more than a few fields a validation library does it from a declaration. That is the next lesson but one; the manual version is worth knowing because it shows what the library is doing.
BUILT-IN NARROWING
typeof === null / == null Array.isArray instanceof
"key" in obj obj.kind === "raw"
a throw or return in a branch removes that case from the rest
assignment narrows too
YOUR OWN CHECKS
function isPhoto(v: unknown): v is Photo
a TYPE PREDICATE - tells the checker what `true` means
returning plain boolean forces the caller to assert, which
discards the check they just did
function assertPhoto(v: unknown): asserts v is Photo
"if this returns, it is a Photo" - narrows the rest of the scope
must be declared with `function`, not an arrow const
NEITHER IS VERIFIED - the body must really prove the claim
EXHAUSTIVENESS
default: return assertNever(upload);
function assertNever(value: never): never { throw ... }
every case handled -> never -> compiles
one missed -> an error naming it
Record<Union, T> requires every member as a key - tidier when
each case maps to a value rather than to logic
WHERE NARROWING STOPS
inside a callback - it may run later
after an await or a call - for a mutable value
through obj.property - the object may have changed
a let reassigned in a loop
fix: capture the narrowed value in a const
TRUTHINESS
if (value) also removes "" and 0
use == null when you mean absence
NARROWING unknown
typeof !== "object" || === null -> reject
as Record<string, unknown> -> one contained assertion
check each field individuallyYou can now teach the checker what your own checks prove, keep a
union exhaustive as it changes, and recognise the four places
narrowing stops applying. The assertNever helper is the single
highest-value thing here — it converts a class of "we forgot to
update that switch" bug into a build failure.
Next is unknown, any, and never, the three types people misuse most. Two of them have appeared repeatedly in this course already; the lesson explains what each actually means and why one of them switches off checking far beyond the line it appears on.
Before you move on, write a type predicate for a shape you
receive from outside, and use it. Then delete the is from the
signature and watch every call site break. That difference is
the value of the annotation, made visible.