User-defined type guards
User-defined type guards return `x is T`. The checker trusts the predicate unsoundly — a lying guard compiles and corrupts every narrowing downstream.
A helper named isUser(x): x is User ships in your shared library. Every call site that passes its check treats the value as a fully-typed User — reads .email, .roles, .id. Months later a production crash: Cannot read properties of undefined (reading 'toLowerCase'). The guard’s body only checked typeof x === "object". It lied. TypeScript believed it, narrowed thousands of call sites to User, and never warned anyone. A type predicate is a promise the checker takes on faith — and a broken promise is invisible until runtime.
What a type predicate is
When you push validation into a helper function, the checker loses track of what the boolean result means — and gives you the full union back. Type predicates solve this: they let a reusable function participate in the same narrowing machinery that typeof and instanceof use inline.
A normal function returning boolean does not narrow anything for the caller — the checker has no idea what the true result means. A type predicate return annotation, arg is T, tells the checker: “when this returns true, treat arg as T; when false, subtract T.”
function isString(x: unknown): x is string {
return typeof x === "string";
}
function handle(v: string | number) {
if (isString(v)) {
v;
// ^? string — the predicate narrowed it, exactly like a built-in guard
return v.toUpperCase();
}
v;
// ^? number — the false branch subtracts string
}The predicate parameter (x / the named arg) must be one of the function’s parameters or this. The return type x is T replaces the plain boolean — at runtime the function still just returns a boolean.
The checker trusts you unsoundly
This is the part to internalize: TypeScript does not verify that the predicate body actually proves the claimed type. The is T annotation is an assertion you make; the checker takes it on faith. A guard whose body does not match its predicate compiles cleanly:
type User = { id: string; email: string; roles: string[] };
// LYING guard — body checks far less than the predicate claims
function isUser(x: unknown): x is User {
return typeof x === "object" && x !== null;
}
const maybe: unknown = { id: "u1" }; // no email, no roles
if (isUser(maybe)) {
maybe.email.toLowerCase();
// ^? the checker thinks maybe: User, email: string
// Runtime: TypeError — email is undefined
}No error at compile time. The predicate is a contract you alone are responsible for honoring. A correct guard checks every field the predicate promises:
function isUser(x: unknown): x is User {
return (
typeof x === "object" && x !== null &&
"id" in x && typeof (x as Record<string, unknown>).id === "string" &&
"email" in x && typeof (x as Record<string, unknown>).email === "string" &&
"roles" in x && Array.isArray((x as Record<string, unknown>).roles)
);
}▸Why this works
Why does TypeScript allow this unsoundness at all? Because runtime validation is undecidable in general — the checker cannot statically prove that an arbitrary boolean expression establishes a structural type. So it draws a deliberate boundary: it verifies your use of the predicate (the narrowing) but delegates the correctness of the predicate body to you. This is the same trust model as as assertions, just packaged as a reusable function. The discipline that fixes it: derive guards from a single schema (Zod’s z.infer + safeParse, io-ts, Valibot) so the validator and the type cannot drift apart.
in-based guards and discriminating without a tag
When members have no shared literal tag, an in check inside a predicate narrows structurally:
type WithData = { data: string };
type WithError = { error: string };
function hasData(r: WithData | WithError): r is WithData {
return "data" in r;
}This works, but it is fragile: if both members could carry a data key (even optionally), the in test no longer distinguishes them. Prefer an explicit discriminant tag when you control the shapes — that is the next lesson.
Guards for arrays and records
A common need is narrowing unknown (e.g. JSON) to a typed array. The predicate must check the container and a sample of elements — but checking only one element is itself an unsound shortcut:
function isStringArray(x: unknown): x is string[] {
return Array.isArray(x) && x.every((e) => typeof e === "string");
}
function isRecordOfNumbers(x: unknown): x is Record<string, number> {
return (
typeof x === "object" && x !== null && !Array.isArray(x) &&
Object.values(x).every((v) => typeof v === "number")
);
}x.every(...) over the whole array is honest; typeof x[0] === "string" (sampling one element) is a lie that the checker still believes. The cost of every is real for huge arrays — which is exactly why parse-once-at-the-boundary (validate the payload when it enters the system) beats re-guarding everywhere.
A type predicate is like a bouncer who stamps a wristband saying 'verified User'. The checker never re-checks the wristband — so if the bouncer is ___, everyone inside is mis-typed.
A glance ahead: assertion functions
A close cousin throws instead of returning a boolean. An assertion function uses asserts x is T: if it returns normally, the checker narrows x to T for the rest of the scope — no if needed:
function assertUser(x: unknown): asserts x is User {
if (!isUser(x)) throw new Error("not a User");
}
function f(x: unknown) {
assertUser(x);
x;
// ^? User — narrowed from here on, because control only continues if it did not throw
}The same unsound trust applies, plus the obligation to actually throw on the negative case. You will study assertion functions in depth in unit 06 (functions-this).
Spot the lying guard
1/3A guard `isUser(x): x is User` whose body only checks `typeof x === 'object'` is passed an object missing required fields. What happens?
Which predicate body honestly proves `x is string[]`?
- 01What does a `x is T` return annotation do that a plain `boolean` return does not?
- 02Explain why a lying type guard is dangerous and how the unsoundness arises.
- 03When narrowing structurally, why prefer a discriminant tag over an `in` guard, and what is the safe array-guard pattern?
User-defined type guards extend the narrowing machinery from the previous lesson to your own functions: a return annotation x is T makes the checker narrow the argument to T on the true branch and subtract it on the false branch. The crucial property is that this trust is unsound — TypeScript never verifies that the predicate body proves the claimed type, so an incomplete or lying guard compiles cleanly and silently corrupts every call site, surfacing only as a runtime crash. Honest guards check every promised field, use every over the whole array rather than sampling, and prefer explicit discriminant tags to in tests when you control the shapes; for production safety, derive guards from a schema so the validator and the type cannot drift. The throwing variant, asserts x is T, is previewed here and detailed in unit 06. Next, discriminated unions turn the fragile in/predicate narrowing into a robust, exhaustiveness-checked tag-based pattern. Now when you review a x is T guard in a pull request, you will check the body field by field — because the compiler never will.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.