open atlas
↑ Back to track
TypeScript type system, deep TS · 02 · 03

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.

TS Middle ◷ 14 min
Level
FoundationsJuniorMiddleSenior

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.

Complete the analogy

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/3
Quiz

A guard `isUser(x): x is User` whose body only checks `typeof x === 'object'` is passed an object missing required fields. What happens?

Quiz

Which predicate body honestly proves `x is string[]`?

Recall before you leave
  1. 01
    What does a `x is T` return annotation do that a plain `boolean` return does not?
  2. 02
    Explain why a lying type guard is dangerous and how the unsoundness arises.
  3. 03
    When narrowing structurally, why prefer a discriminant tag over an `in` guard, and what is the safe array-guard pattern?
Recap

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.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources1
expand
  1. 01

Trademarks belong to their respective owners. Editorial reference only.