RizTech Academy logo
RizTech Academy
Unions and NarrowingLesson 4 of 525 min

Writing your own type guards

The built-in narrowing tools — typeof, in, instanceof, the discriminant check — cover most cases. But sometimes you need to narrow based on a custom condition the compiler cannot follow on its own: "is this value a valid User?" You teach TypeScript to narrow by writing a type guard — a function whose return type says "if this returns true, the argument is this type". This lesson is writing them, and the related assertion functions.

The problem type guards solve

Suppose you have an unknown value (from an API, say) and a function that checks whether it is a User:

type User = { name: string; email: string };

function isUser(value: unknown): boolean {
  return (
    typeof value === "object" && value !== null &&
    "name" in value && "email" in value
  );
}

const data: unknown = JSON.parse(input);
if (isUser(data)) {
  data.name;      // error! data is still 'unknown' — the boolean return told TypeScript nothing
}

The check is correct at runtime, but isUser returns a plain boolean, which tells TypeScript nothing about data's type. Inside the if, data is still unknown, so data.name errors. The compiler does not know that "isUser returned true" means "data is a User" — you have to tell it.

The type predicate — value is Type

You teach TypeScript by giving the function a special return type: value is Type (a "type predicate") instead of boolean:

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" && value !== null &&
    "name" in value && "email" in value &&
    typeof (value as User).name === "string" &&
    typeof (value as User).email === "string"
  );
}

const data: unknown = JSON.parse(input);
if (isUser(data)) {
  data.name.toUpperCase();     // fine! data is narrowed to User inside the guard
}

The return type value is User says: "this function returns a boolean, and when it returns true, the argument is a User." Now if (isUser(data)) narrows data to User inside the branch — exactly like typeof does for primitives, but for your custom check. A type guard is a function that returns a type predicate, turning a runtime check into a compile-time narrowing.

Note the responsibility this carries: TypeScript trusts your predicate. If your runtime check is wrong — you claim value is User but only checked name, not email — TypeScript believes you, and the safety is only as good as your check. A type guard is a promise; make the runtime logic actually verify what the predicate claims.

Guarding array elements — filter with a guard

A genuinely useful application: narrowing the elements of an array. filter with a type guard narrows the resulting array's type:

const values: (string | null)[] = ["Pune", null, "Mumbai", null];

const strings = values.filter((v): v is string => v !== null);
// strings has type string[] — the nulls are removed AND the type reflects it

Without the : v is string predicate, filter would return (string | null)[] (TypeScript cannot know your callback removed the nulls). With the predicate, filter returns string[]. This is the typed version of "drop the nulls", and it is a common, elegant use of a type guard inline. (For this specific null case, TypeScript's built-in filter overloads sometimes handle it, but the explicit guard is reliable and clear.)

Assertion functions — narrow or throw

A cousin of the type guard is an assertion function: instead of returning a boolean, it throws if the condition fails, and narrows the type for all code after the call:

function assertIsUser(value: unknown): asserts value is User {
  if (
    typeof value !== "object" || value === null ||
    !("name" in value) || !("email" in value)
  ) {
    throw new Error("Not a valid User");
  }
}

const data: unknown = JSON.parse(input);
assertIsUser(data);
data.name.toUpperCase();     // fine — after the assertion, data is User (or we threw)

The return type asserts value is User says: "if this function returns normally, the argument is a User (otherwise it threw)." So after assertIsUser(data), TypeScript narrows data to User for the rest of the scope — no if needed, because if the value were not a User, the function would have thrown and execution would have stopped. This is the "validate at the boundary, then proceed with a guaranteed type" pattern, and it reads very cleanly for input validation.

There is also asserts condition (without is) for asserting a plain boolean, which narrows based on the condition — useful for assert(x !== null) style checks.

When to write one — and when not to

Type guards and assertion functions are powerful, but do not reach for them when a built-in narrowing works:

  • A union of primitives? Use typeof — no guard needed.
  • A discriminated union? Check the discriminant — no guard needed.
  • Objects differing by a property? Use in — no guard needed.
  • Class instances? Use instanceof — no guard needed.

Write a custom type guard when the check is genuinely custom — validating the shape of external data, a condition that spans multiple properties, or a runtime check TypeScript cannot express otherwise. And for validating external data specifically, a schema library like Zod (real-projects module) generates the guard for you and does the runtime check reliably — often better than a hand-written guard. Reach for a hand-written guard when the check is small and specific; reach for a validation library when you are parsing a whole API response.

Check your work

Why a boolean-returning check does not narrow. It tells TypeScript nothing about the argument's type; inside the if, the value is unchanged.

The type-predicate return type. value is Type — "when this returns true, the argument is Type" — which makes the function a narrowing type guard.

The responsibility a type guard carries. TypeScript trusts the predicate; the safety is only as good as your runtime check.

Guarding array elements. filter((v): v is string => ...) narrows the result to string[].

What an assertion function does. Throws if the condition fails; its asserts value is Type return narrows the value for all code after the call, no if needed.

asserts value is Type versus asserts condition. The first narrows a value to a type; the second narrows based on a boolean condition.

When to write a custom guard. For genuinely custom checks (external-data shape, multi-property conditions) — not when typeof/in/instanceof/a discriminant works.

The better tool for whole API responses. A validation library like Zod, which does the runtime check and produces the type.

Practice

  1. Write isUser returning a plain boolean and confirm the value stays unknown inside the if.
  2. Change the return type to value is User and confirm the value now narrows. Access a property inside the branch.
  3. Deliberately write a guard that only checks name but claims value is User. Note TypeScript trusts it — and reason about the bug this could cause.
  4. Use filter((v): v is string => v !== null) on a (string | null)[] and confirm the result is string[].
  5. Write an assertIsUser assertion function and confirm the value narrows after the call, with no if.
  6. Use asserts condition to write assert(x !== null) and confirm it narrows.
  7. For four checks, decide whether a built-in narrowing (typeof/in/instanceof/discriminant) or a custom guard is right, and justify each.

Official documentation

Next: exhaustiveness checking with never.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship