Type guards and predicates
Functions that return true and quietly teach the compiler what you've already verified.
You've got a value typed as unknown — a JSON blob, a localStorage entry, a result from a third-party callback. You know how to check that it has the shape you want. You don't yet know how to tell the compiler you've checked. That's what type guards are for: small functions that return true or false, and which TypeScript reads as proof.
Built-in guards
Three operators are built into the language and each one narrows a type as you use it. They're the simplest type guards you have.
typeof for primitives. After if (typeof name === "string"), the compiler treats name as a string inside the if.
instanceof for classes. After if (err instanceof Error), you can read err.message without a complaint.
in for object keys. After if ("email" in user), the compiler knows the property exists.
function describe(value: unknown) {
if (typeof value === "string") {
return value.toUpperCase(); /* string here */
}
if (value instanceof Date) {
return value.toISOString(); /* Date here */
}
if (value && typeof value === "object" && "name" in value) {
return String(value.name); /* object with a name */
}
return "unknown";
}Each branch reads a different shape. TypeScript walks down the function and tracks what's been ruled out at every step. Inside the first branch, value is a string. Inside the second, it's a Date. The compiler does this for free — no extra annotations.
The third branch shows the catch with in: TypeScript can't read into the value's structure unless you first prove it's a non-null object. That's what value && typeof value === "object" is for.
typeof null is "object" — a famous JavaScript bug that's now permanent. Always guard typeof x === "object" with a separate x !== null or x &&.
if (typeof value === "object" && value !== null), what type does TypeScript infer for value if it started as unknown?Custom predicates
The built-in guards run out fast. You can't typeof your way into "is this a User" — there's no language operator for "has a string name and a number age." You write the check yourself, and you give the function a special return type.
type User = { name: string; age: number };
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"name" in value &&
typeof (value as { name: unknown }).name === "string" &&
"age" in value &&
typeof (value as { age: unknown }).age === "number"
);
}The signature ends with value is User — a type predicate. It tells the compiler: "I return a boolean, and when it's true, this argument is a User." The body has to actually do that work; TypeScript won't check whether your check is right. The predicate is a contract you sign in the type and uphold in the body.
The as casts inside the body look a bit awkward. They're there because once you've proven value is an object with a name key, you still have to read that key as unknown before you can typeof it. The casts narrow one step at a time without claiming anything you haven't earned.
async function loadProfile() {
const res = await fetch("/api/me");
const data: unknown = await res.json();
if (!isUser(data)) {
throw new Error("not a user");
}
return data.name + " (" + data.age + ")"; /* data is User here */
}After isUser(data) returns true, TypeScript treats data as User for the rest of the block. The compiler is taking your predicate at its word — that's what predicates are. The benefit is that every read after the check is type-safe, without any more casting.
A predicate is only as honest as its body. If your isUser forgets to check age, the compiler will still trust the true return and let you read data.age as a number. The bug becomes a silent runtime crash.
function isPost(v: unknown): v is Post but the body always returns true. What does the compiler do?How narrowing flows
Narrowing isn't only inside if blocks. The compiler tracks ruled-out types through else, through early returns, and through reassignments.
function format(value: string | number | null) {
if (value === null) {
return "(none)";
}
/* null is gone — value is string | number here */
if (typeof value === "number") {
return value.toFixed(2); /* number */
}
return value.trim(); /* string — by elimination */
}Trace it line by line. The parameter starts as string | number | null. After the first if, the only way to reach the next line is if value was not null — so the type drops null. After the second if, the only way to reach the last line is if value is not a number — so the type drops to string. The compiler is doing simple set subtraction with each branch.
This is why early returns are friendly to TypeScript. Each one removes a possibility, and the rest of the function gets narrower for free.
string | undefined. After if (!value) return "", what type does value have on the next line?Where predicates fall short
Predicates are small, and small is mostly good. But two limits show up the moment your shapes get serious.
The first: writing one predicate per type by hand gets old fast. A User is straightforward. A User with an address, an array of posts, and a nullable avatar turns the body into a maze. Worse, when the type changes, you have to remember to change the predicate. The two can drift apart silently.
The second: predicates only return true or false. They throw away the reason something didn't match. If isUser(data) returns false, you don't know whether name was missing, age was a string, or the whole thing was a number. Debugging a "no it doesn't match" with no detail is painful.
type Order = {
id: string;
items: { sku: string; qty: number }[];
customer: { name: string; email: string | null };
};
/* Writing isOrder by hand is a 25-line chore that a future you will resent */This is the gap that schema validation libraries — coming up in lesson 4 — fill. They let you describe the shape once and get both a type predicate (kind of) and a useful error message for free. Hand-written predicates still have a role for small, stable shapes; for anything else, you'll want a library.
A custom predicate is the right tool when the shape is small and lives in one file. The moment your check spans more than ten lines, reach for a schema instead.
User shape: one is a predicate returning true / false, the other parses with a schema and returns { ok: true, data } or { ok: false, error }. What does the schema give you that the predicate doesn't?if (typeof user === "object" && user !== null && "email" in user), you read user.email.toLowerCase(). What's wrong?