cheat sheet

Type Guards

User-defined type guards narrow types at runtime using the `is` predicate, assertion functions, generic guards, and class-based patterns. Covers API validation, DOM guards, and when to use Zod.

Type Guards

What it is

Type guards are functions or expressions that narrow a type at runtime. When TypeScript cannot determine a type from built-in checks (typeof, instanceof, in), you can write a user-defined type guard — a function whose return type uses the value is Type predicate syntax. When the function returns true, TypeScript narrows the argument to the specified type within the calling scope.

is predicate

A user-defined type guard is a function that returns value is SomeType:

typescript
function isString(val: unknown): val is string {
  return typeof val === "string";
}

function processInput(val: unknown) {
  if (isString(val)) {
    console.log(val.toUpperCase()); // val: string
  }
}

The predicate tells TypeScript: "if this function returns true, treat the first argument as SomeType in the calling scope."

More complex examples:

typescript
interface Cat { kind: "cat"; meow(): void }
interface Dog { kind: "dog"; bark(): void }

function isCat(animal: Cat | Dog): animal is Cat {
  return animal.kind === "cat";
}

function interact(animal: Cat | Dog) {
  if (isCat(animal)) {
    animal.meow(); // animal: Cat
  } else {
    animal.bark(); // animal: Dog
  }
}

Assertion functions

Assertion functions throw when the condition is false and use the asserts keyword. They are useful for runtime validation at boundaries like function entry points.

typescript
function assertDefined<T>(val: T | undefined | null, label = "value"): asserts val is T {
  if (val == null) {
    throw new Error(`Expected ${label} to be defined, got ${val}`);
  }
}

function processUser(user: User | undefined) {
  assertDefined(user, "user"); // throws if undefined
  console.log(user.name);      // user: User — no null check needed after this
}

Assert a boolean condition:

typescript
function assert(condition: boolean, message: string): asserts condition {
  if (!condition) throw new Error(message);
}

function divide(a: number, b: number): number {
  assert(b !== 0, "Cannot divide by zero");
  return a / b;
}

Assert a specific type:

typescript
function assertIsString(val: unknown): asserts val is string {
  if (typeof val !== "string") {
    throw new TypeError(`Expected string, got ${typeof val}`);
  }
}

const raw: unknown = JSON.parse('"hello"');
assertIsString(raw);
console.log(raw.toUpperCase()); // raw: string

Class-based type guard with instanceof

Using instanceof as a type guard is built-in, but you can wrap it in a function for reuse and composition:

typescript
class ValidationError extends Error {
  constructor(public field: string, message: string) {
    super(message);
    this.name = "ValidationError";
  }
}

class AuthError extends Error {
  constructor(public statusCode: 401 | 403, message: string) {
    super(message);
    this.name = "AuthError";
  }
}

function isValidationError(err: unknown): err is ValidationError {
  return err instanceof ValidationError;
}

function isAuthError(err: unknown): err is AuthError {
  return err instanceof AuthError;
}

async function handleRequest() {
  try {
    await someOperation();
  } catch (err) {
    if (isValidationError(err)) {
      return { status: 400, field: err.field, message: err.message };
    }
    if (isAuthError(err)) {
      return { status: err.statusCode, message: err.message };
    }
    throw err; // re-throw unexpected errors
  }
}

Generic type guard

A generic type guard delegates the per-element check to a callback:

typescript
function isArrayOf<T>(
  arr: unknown,
  guard: (x: unknown) => x is T
): arr is T[] {
  return Array.isArray(arr) && arr.every(guard);
}

function isString(val: unknown): val is string {
  return typeof val === "string";
}

function isNumber(val: unknown): val is number {
  return typeof val === "number";
}

const raw: unknown = ["a", "b", "c"];

if (isArrayOf(raw, isString)) {
  console.log(raw.join(", ")); // raw: string[]
}

// Composing guards for object shapes
function isRecord(val: unknown): val is Record<string, unknown> {
  return typeof val === "object" && val !== null && !Array.isArray(val);
}

Discriminated union narrowing with a type guard function

Wrapping a discriminant check inside a named type guard function gives the narrowed branch a descriptive name and keeps call-site conditionals readable. Use this pattern when the same discriminant check is repeated across multiple components or when you want to export the guard as part of a module's public API.

typescript
type ApiResult<T> =
  | { status: "ok"; data: T }
  | { status: "error"; code: number; message: string };

function isOk<T>(result: ApiResult<T>): result is { status: "ok"; data: T } {
  return result.status === "ok";
}

function isError<T>(
  result: ApiResult<T>
): result is { status: "error"; code: number; message: string } {
  return result.status === "error";
}

async function fetchData(): Promise<ApiResult<User[]>> {
  const res = await fetch("/api/users");
  return res.json();
}

const result = await fetchData();

if (isOk(result)) {
  result.data.forEach((u) => console.log(u.name));
} else {
  console.error(`[${result.code}] ${result.message}`);
}

as const satisfies guard

Using as const narrows a value to its literal types, and satisfies validates it against a type without widening:

typescript
const ROLES = ["admin", "user", "moderator"] as const;
type Role = (typeof ROLES)[number]; // "admin" | "user" | "moderator"

function isRole(val: unknown): val is Role {
  return typeof val === "string" && (ROLES as readonly string[]).includes(val);
}

const rawRole: unknown = "admin";
if (isRole(rawRole)) {
  console.log(rawRole); // rawRole: "admin" | "user" | "moderator"
}

satisfies lets you validate an object against a type while keeping the precise inferred type:

typescript
type Config = { host: string; port: number };

const config = {
  host: "localhost",
  port: 3000,
} satisfies Config;

// config.host is type "localhost" (literal), not just string

Common patterns

API response validator

A runtime check for an API response that verifies required fields exist:

typescript
interface UserResponse {
  id: number;
  name: string;
  email: string;
}

function isUserResponse(val: unknown): val is UserResponse {
  if (typeof val !== "object" || val === null) return false;
  const obj = val as Record<string, unknown>;
  return (
    typeof obj["id"] === "number" &&
    typeof obj["name"] === "string" &&
    typeof obj["email"] === "string"
  );
}

async function fetchUser(id: number): Promise<UserResponse> {
  const res = await fetch(`/api/users/${id}`);
  const data: unknown = await res.json();

  if (!isUserResponse(data)) {
    throw new Error("Invalid user response shape");
  }

  return data; // UserResponse
}

DOM element type guard

typescript
function isHTMLInputElement(el: Element | null): el is HTMLInputElement {
  return el instanceof HTMLInputElement;
}

function isHTMLButtonElement(el: Element | null): el is HTMLButtonElement {
  return el instanceof HTMLButtonElement;
}

const el = document.getElementById("submit");

if (isHTMLButtonElement(el)) {
  el.disabled = true;  // el: HTMLButtonElement
}

const input = document.querySelector(".search-input");
if (isHTMLInputElement(input)) {
  console.log(input.value); // input: HTMLInputElement
}

JSON parsing with type safety

typescript
function safeJsonParse<T>(
  json: string,
  guard: (val: unknown) => val is T
): T | null {
  try {
    const val = JSON.parse(json);
    return guard(val) ? val : null;
  } catch {
    return null;
  }
}

interface Settings {
  theme: "dark" | "light";
  language: string;
}

function isSettings(val: unknown): val is Settings {
  if (typeof val !== "object" || val === null) return false;
  const obj = val as Record<string, unknown>;
  return (
    (obj["theme"] === "dark" || obj["theme"] === "light") &&
    typeof obj["language"] === "string"
  );
}

const rawJson = localStorage.getItem("settings") ?? "{}";
const settings = safeJsonParse(rawJson, isSettings);

if (settings !== null) {
  applyTheme(settings.theme); // settings: Settings
}

Zod as a runtime type guard library

Zod is the most widely used schema validation library for TypeScript. Instead of writing manual type guard functions, you define schemas that produce both a TypeScript type and a runtime validator.

typescript
import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  role: z.enum(["admin", "user", "moderator"]),
});

type User = z.infer<typeof UserSchema>; // inferred from schema — no duplication

// parse throws on invalid input
const user = UserSchema.parse(await res.json()); // user: User

// safeParse never throws
const result = UserSchema.safeParse(unknownData);
if (result.success) {
  console.log(result.data.name); // result.data: User
} else {
  console.error(result.error.flatten());
}

// Use as a type guard
function isUser(val: unknown): val is User {
  return UserSchema.safeParse(val).success;
}

For complex validation (nested objects, unions, arrays, coercion, custom error messages), Zod is strongly preferred over hand-written type guards. It also generates better error messages.

Composing type guards

Type guards compose naturally with && and ||:

typescript
const isNonNullString = (val: unknown): val is string =>
  typeof val === "string" && val.length > 0;

const isPositiveNumber = (val: unknown): val is number =>
  typeof val === "number" && val > 0 && Number.isFinite(val);

// Higher-order guard combinator
function nullable<T>(guard: (val: unknown) => val is T) {
  return (val: unknown): val is T | null => val === null || guard(val);
}

const isNullableString = nullable(isNonNullString);

Predicate functions — the deep dive

A user-defined type guard is a function whose return type is parameter is T. The semantics: the compiler treats the function as opaque (it does not verify that the body actually checks what the signature says), but trusts the predicate when narrowing at call sites. This is a contract between the function author and the compiler — if your predicate lies, the compiler will silently produce unsound narrowings downstream.

typescript
function isString(val: unknown): val is string {
  return typeof val === "string";
}

// Liar predicate — compiler trusts it anyway
function lies(val: unknown): val is string {
  return true; // always says "yes"
}

const x: unknown = 42;
if (lies(x)) {
  console.log(x.toUpperCase()); // crashes at runtime — x is a number
}

The runtime contract: a predicate must return true if and only if the value matches the claimed type. Audit your predicates carefully; treat them like unsafe blocks in Rust.

Predicate scope and this

A predicate's is clause can also apply to this rather than a parameter, useful inside class methods:

typescript
class Box<T> {
  constructor(private value: T | null) {}

  hasValue(): this is Box<T> & { readonly value: T } {
    return this.value !== null;
  }

  get(): T | null {
    return this.value;
  }
}

const b = new Box<string>("hello");
if (b.hasValue()) {
  console.log(b.value.toUpperCase()); // b.value: string (narrowed via this-predicate)
}

This is the pattern jQuery used for chainable methods and the one TypeScript's own AST uses for Node.isStringLiteral() etc.

Predicate against a generic

A predicate can carry a generic to narrow to a parametric type. The compiler infers T from the call site.

typescript
function isInstanceOf<T>(ctor: new (...args: never[]) => T, val: unknown): val is T {
  return val instanceof ctor;
}

const err: unknown = new Error("boom");
if (isInstanceOf(Error, err)) {
  console.log(err.message); // err: Error
}

This is structurally identical to a wrapper around instanceof but composes better when threaded into higher-order helpers (filter, find).

Predicate vs assertion vs return-type

Three ways to "narrow" with a function. Pick the one that fits the calling style.

FormSignatureNarrows when...Call style
Predicate(x: unknown) => x is TReturns trueif (isT(x)) { ... }
Assertion(x: unknown) => asserts x is TReturns without throwingassertIsT(x); /* x: T below */
Plain return(x: unknown) => T | nullResult is not nullconst v = parseT(x); if (v) ...
Throwing parser(x: unknown) => T (throws)Returns at allconst v = parseT(x); /* v: T */

When to use which:

  • Predicate — composing in conditionals, filtering arrays (xs.filter(isString) narrows to string[]).
  • Assertion — top-of-function preconditions, narrowing for the rest of a block, environment-variable checks.
  • Plain return — optional values where absence is meaningful, e.g. findUser(id): User | null.
  • Throwing parser — boundary validation where invalid input is unrecoverable, e.g. Zod parse().

Generic deep type guards

Building structurally-correct deep guards for nested objects is verbose by hand. The standard pattern composes small per-field guards into a single per-shape guard.

typescript
interface Address {
  street: string;
  city: string;
  zip: string;
}

interface User {
  id: number;
  name: string;
  email: string;
  address: Address;
  tags: string[];
}

const isString = (v: unknown): v is string => typeof v === "string";
const isNumber = (v: unknown): v is number => typeof v === "number";
const isRecord = (v: unknown): v is Record<string, unknown> =>
  typeof v === "object" && v !== null && !Array.isArray(v);

function isAddress(v: unknown): v is Address {
  if (!isRecord(v)) return false;
  return isString(v.street) && isString(v.city) && isString(v.zip);
}

function isUser(v: unknown): v is User {
  if (!isRecord(v)) return false;
  return (
    isNumber(v.id) &&
    isString(v.name) &&
    isString(v.email) &&
    isAddress(v.address) &&
    Array.isArray(v.tags) &&
    v.tags.every(isString)
  );
}

const raw: unknown = JSON.parse(
  '{"id":1,"name":"Alice","email":"a@example.com","address":{"street":"1 St","city":"Town","zip":"00000"},"tags":["a","b"]}'
);
if (isUser(raw)) console.log(raw.name);

The verbosity is why most teams reach for Zod (or Valibot, or io-ts) once shapes get nested.

Generic guard combinators

A small set of higher-order helpers covers most composition needs.

typescript
type Guard<T> = (val: unknown) => val is T;

const isAny  = (_: unknown): _ is unknown => true;
const isNull = (v: unknown): v is null    => v === null;
const isUndef = (v: unknown): v is undefined => v === undefined;
const isStringG = (v: unknown): v is string => typeof v === "string";
const isNumberG = (v: unknown): v is number => typeof v === "number" && !Number.isNaN(v);
const isBoolean = (v: unknown): v is boolean => typeof v === "boolean";

function or<A, B>(a: Guard<A>, b: Guard<B>): Guard<A | B> {
  return (v): v is A | B => a(v) || b(v);
}

function and<A, B>(a: Guard<A>, b: Guard<B>): Guard<A & B> {
  return (v): v is A & B => a(v) && b(v);
}

function not<T>(g: Guard<T>) {
  return (v: unknown): boolean => !g(v);
}

function optional<T>(g: Guard<T>): Guard<T | undefined> {
  return or(g, isUndef);
}

function arrayOf<T>(g: Guard<T>): Guard<T[]> {
  return (v): v is T[] => Array.isArray(v) && v.every(g);
}

function recordOf<T>(g: Guard<T>): Guard<Record<string, T>> {
  return (v): v is Record<string, T> =>
    typeof v === "object" && v !== null && Object.values(v as object).every(g);
}

const isStringOrNumber = or(isStringG, isNumberG);
const isStringArray    = arrayOf(isStringG);
const isOptionalString = optional(isStringG);

This gets you 80% of Zod's guarding power in 30 lines. For runtime validation with detailed error messages, refundable form errors, transformations, async refinements, or schema introspection, jump to Zod.

Type guard vs instanceof vs typeof vs Array.isArray

The built-in narrowing checks cover the most common cases. Use the built-in form when applicable — it is faster, more familiar, and has no extra function call.

CheckNarrowsWhen to use
typeof x === "string"stringPrimitive type checks
typeof x === "object"object | nullAlways pair with null check
x instanceof DateDateClass instances within the same realm
Array.isArray(x)unknown[]Cross-realm-safe array detection
"prop" in x{ prop: unknown } & XVariant-discrimination in unions
User-defined guardAnythingWhen the built-ins don't suffice

Prefer Array.isArray() over x instanceof Array because the latter fails for arrays created in another realm (iframe, worker). The same applies to typed arrays — use ArrayBuffer.isView(x) instead of instanceof Uint8Array when crossing iframes.

typescript
function example(val: unknown) {
  if (Array.isArray(val)) {
    // val: unknown[] — cross-realm safe
  }

  if (val instanceof Date) {
    // val: Date — fails for Dates from another realm
  }

  if (typeof val === "object" && val !== null && Symbol.toStringTag in val) {
    // For cross-realm Map/Set/etc, check the toStringTag
    const tag = (val as { [Symbol.toStringTag]: unknown })[Symbol.toStringTag];
    if (tag === "Map") {
      // val is a Map (from any realm)
    }
  }
}

Pairing with runtime validation (Zod)

Hand-written guards work but accumulate bugs over time as types evolve. Zod ties the type and validator together so they cannot drift. The TypeScript type is inferred from the schema, not duplicated.

typescript
import { z } from "zod";

const UserSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1).max(120),
  email: z.string().email(),
  role: z.enum(["admin", "user", "moderator"]),
  createdAt: z.coerce.date(),
  preferences: z.object({
    theme: z.enum(["light", "dark", "auto"]),
    notifications: z.boolean(),
  }).optional(),
});

type User = z.infer<typeof UserSchema>;

// Strict — throws on first error
const u1 = UserSchema.parse(await fetch("/api/users/1").then(r => r.json()));

// Soft — never throws, returns a discriminated union
const result = UserSchema.safeParse(unknownInput);
if (result.success) {
  console.log(result.data.name);
} else {
  for (const issue of result.error.issues) {
    console.error(`${issue.path.join(".")}: ${issue.message}`);
  }
}

// Compose into a type-guard if you need one
function isUser(v: unknown): v is User {
  return UserSchema.safeParse(v).success;
}

Why Zod over hand-rolled guards:

  • No duplication — schema is the single source of truth; type is derived.
  • Composable.optional(), .refine(), .transform(), .brand(), .array(), .union(), .discriminatedUnion() chain naturally.
  • Detailed errors — every failure includes a path and message.
  • Coercionz.coerce.date(), z.coerce.number() handle common input-shape mismatches.
  • Branded output.brand<"Email">() produces a nominal type the rest of the codebase can rely on.

Alternatives in the same ecosystem with similar ergonomics: Valibot (lighter, more modular), io-ts (functional-first, older), ArkType (TypeScript-syntax schemas), Effect Schema (part of Effect-TS).

Type guards inside Array filter

Array.prototype.filter accepts a callback that may be a type guard, and TypeScript narrows the result array accordingly. This is the most ergonomic use of predicates.

typescript
const mixed: (string | number | null | undefined)[] = ["a", 1, null, "b", undefined, 2];

const isString = (v: unknown): v is string => typeof v === "string";
const isDefined = <T>(v: T | null | undefined): v is T => v != null;

const strings = mixed.filter(isString);
// strings: string[] — narrowed from (string | number | null | undefined)[]

const nonNull = mixed.filter(isDefined);
// nonNull: (string | number)[] — null and undefined removed

A common bug is to use a bare arrow function that returns a boolean — the array stays as the original type. The predicate is T return type is what triggers the filter narrowing.

typescript
// Subtly wrong — strings2 is still (string | number | null | undefined)[]
const strings2 = mixed.filter((v) => typeof v === "string");

// Correct
const strings3 = mixed.filter(isString); // string[]
const strings4 = mixed.filter((v): v is string => typeof v === "string"); // string[]

Type guards for unknown error in catch

Since TS 4.4, catch clauses default to unknown. Narrowing a caught error is the single most-frequently-needed type-guard pattern.

typescript
function getErrorMessage(err: unknown): string {
  if (err instanceof Error) return err.message;
  if (typeof err === "string") return err;
  if (
    typeof err === "object" &&
    err !== null &&
    "message" in err &&
    typeof (err as { message: unknown }).message === "string"
  ) {
    return (err as { message: string }).message;
  }
  return String(err);
}

try {
  await someAsyncOp();
} catch (err) {
  console.error(getErrorMessage(err));
}

A standard utility every codebase needs. Variants for Node code, HTTP status, validation errors etc. follow the same pattern.

Type guards inside React/JSX

Conditional rendering depends on narrowing the children prop or state union. A type guard turns "the compiler doesn't know which branch" into "exhaustively render every variant".

typescript
type LoadState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; message: string };

function isSuccess<T>(s: LoadState<T>): s is { status: "success"; data: T } {
  return s.status === "success";
}

function Loader<T>({ state, render }: { state: LoadState<T>; render: (d: T) => unknown }) {
  if (state.status === "idle")    return "Idle";
  if (state.status === "loading") return "Loading…";
  if (isSuccess(state))           return render(state.data);
  return `Error: ${state.message}`;
}

The inline state.status === "..." checks narrow without a named guard; the named isSuccess form is useful when you need to filter an array or pass the predicate as a callback.

Common pitfalls

  1. Lying predicates — a val is T function that doesn't actually verify T causes silent unsoundness downstream. Test predicates with property-based tests.
  2. Forgetting is in the return typefunction isString(v: unknown): boolean does not narrow. Must be v is string.
  3. filter((v) => typeof v === "string") not narrowing — without an explicit predicate return type, TypeScript keeps the original array type. Wrap as (v): v is string => ... or use a named guard.
  4. val !== null && val !== undefined without typing — write a generic isDefined<T>(v: T | null | undefined): v is T.
  5. Hand-written guards drifting from types — when the type adds a field but the guard does not, the guard is unsound. Either generate guards from types (ts-auto-guard, typescript-is) or use Zod where the type is derived from the schema.
  6. instanceof across realms — fails for cross-frame/cross-worker objects. Use tag-based checks (Symbol.toStringTag, Array.isArray).
  7. in operator with optional properties"foo" in obj is true even when foo === undefined. Combine with a value check if you mean "set to something".
  8. Casting inside a guard bodyreturn (val as User).id !== undefined short-circuits the check and accepts wrong shapes. Always check structurally.
  9. Assertion functions with implicit any returnfunction assertX(x): asserts x is X needs the parameter typed (otherwise TS errors on the asserts clause).
  10. asserts not enforcing throw — the compiler doesn't verify that you throw when the condition fails. Audit assertions like predicates.
  11. Generic type-guard combinators losing inference — sometimes or(isA, isB) infers unknown instead of A | B. Annotate the generic on the call site if inference falters.
  12. Overconfidence in shallow checkstypeof obj === "object" && obj !== null does not guarantee the shape of the object. Drill into each field you actually use.

Real-world recipes

Recipe 1 — Universal getErrorMessage

Every codebase needs this. Drop-in for catch (err) clauses.

typescript
export function getErrorMessage(err: unknown): string {
  if (err instanceof Error) return err.message;
  if (typeof err === "string") return err;
  if (typeof err === "object" && err !== null) {
    const obj = err as { message?: unknown; toString?: () => string };
    if (typeof obj.message === "string") return obj.message;
    if (typeof obj.toString === "function") return obj.toString();
  }
  return String(err);
}

try {
  JSON.parse("not json");
} catch (err) {
  console.error(getErrorMessage(err));
}

Output:

text
Unexpected token 'o', "not json" is not valid JSON

Recipe 2 — Node ENOENT detection

A robust guard that narrows arbitrary unknown errors to the Node "missing file" variety.

typescript
import { readFile } from "node:fs/promises";

interface NodeSystemError extends Error {
  code: string;
  syscall: string;
  path?: string;
}

function isNodeSystemError(err: unknown): err is NodeSystemError {
  return (
    err instanceof Error &&
    typeof (err as { code?: unknown }).code === "string" &&
    typeof (err as { syscall?: unknown }).syscall === "string"
  );
}

async function readOrDefault(path: string, fallback: string): Promise<string> {
  try {
    return await readFile(path, "utf8");
  } catch (err) {
    if (isNodeSystemError(err) && err.code === "ENOENT") {
      return fallback;
    }
    throw err;
  }
}

Recipe 3 — Zod-powered API client

A fetch wrapper that validates every response against a schema and returns a typed Result. No runtime trust in the server is assumed.

typescript
import { z } from "zod";

type Result<T, E> = { ok: true; data: T } | { ok: false; error: E };

async function getJson<T extends z.ZodTypeAny>(
  url: string,
  schema: T,
): Promise<Result<z.infer<T>, string>> {
  try {
    const res = await fetch(url);
    if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
    const json: unknown = await res.json();
    const parsed = schema.safeParse(json);
    if (!parsed.success) {
      return { ok: false, error: parsed.error.issues.map(i => i.message).join("; ") };
    }
    return { ok: true, data: parsed.data };
  } catch (err) {
    return { ok: false, error: err instanceof Error ? err.message : String(err) };
  }
}

const User = z.object({ id: z.number(), name: z.string() });

const result = await getJson("https://api.example.com/users/1", User);
if (result.ok) console.log(result.data.name);
else console.error(result.error);

Recipe 4 — Generic deep-equality guard for tuples

A predicate that narrows an unknown value to a tuple of specific element types.

typescript
type Guard<T> = (v: unknown) => v is T;

function tupleOf<A, B>(a: Guard<A>, b: Guard<B>): Guard<[A, B]>;
function tupleOf<A, B, C>(a: Guard<A>, b: Guard<B>, c: Guard<C>): Guard<[A, B, C]>;
function tupleOf(...guards: Guard<unknown>[]) {
  return (v: unknown): boolean =>
    Array.isArray(v) && v.length === guards.length && v.every((x, i) => guards[i](x));
}

const isString = (v: unknown): v is string => typeof v === "string";
const isNumber = (v: unknown): v is number => typeof v === "number";

const isPair = tupleOf(isString, isNumber) as Guard<[string, number]>;

const raw: unknown = ["age", 42];
if (isPair(raw)) {
  const [label, value] = raw;
  console.log(`${label}: ${value}`);
}

Output:

text
age: 42

Recipe 5 — Class registry with instanceof guards

A plugin system that registers handlers keyed by class. The registry uses instanceof guards to dispatch.

typescript
abstract class Event { }
class ClickEvent  extends Event { constructor(public x: number, public y: number) { super() } }
class KeyEvent    extends Event { constructor(public key: string) { super() } }
class ResizeEvent extends Event { constructor(public w: number, public h: number) { super() } }

type Handler<E extends Event> = (e: E) => void;

class EventBus {
  private handlers = new Map<new (...args: never[]) => Event, Handler<Event>[]>();

  on<E extends Event>(ctor: new (...args: never[]) => E, fn: Handler<E>): void {
    const list = this.handlers.get(ctor) ?? [];
    list.push(fn as Handler<Event>);
    this.handlers.set(ctor, list);
  }

  emit(event: Event): void {
    for (const [ctor, list] of this.handlers) {
      if (event instanceof ctor) list.forEach(fn => fn(event));
    }
  }
}

const bus = new EventBus();
bus.on(ClickEvent, e => console.log(`click ${e.x},${e.y}`));
bus.on(KeyEvent,   e => console.log(`key ${e.key}`));
bus.emit(new ClickEvent(10, 20));
bus.emit(new KeyEvent("Enter"));

Output:

text
click 10,20
key Enter

Recipe 6 — Branded type construction with a guard

Brand a string as a validated Email only via the guard. Downstream functions accept Email instead of string, so passing raw input is a compile error.

typescript
type Email = string & { readonly __brand: "Email" };

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

function isEmail(v: unknown): v is Email {
  return typeof v === "string" && EMAIL_RE.test(v);
}

function parseEmail(v: unknown): Email {
  if (!isEmail(v)) throw new TypeError(`not an email: ${String(v)}`);
  return v;
}

function sendInvite(to: Email): void {
  console.log(`-> ${to}`);
}

const raw: unknown = "alice@example.com";
sendInvite(parseEmail(raw));
// sendInvite("alice@example.com"); // Error — raw string is not Email

See the branded types article for the full pattern. Guards are the canonical constructors for brands.

Recipe 7 — Discriminated union guard factory

Generate per-variant type guards from a discriminated union automatically. Each guard narrows to a specific variant.

typescript
type Msg =
  | { kind: "text"; content: string }
  | { kind: "image"; url: string; alt: string }
  | { kind: "video"; url: string; durationSec: number };

function variantGuard<T extends { kind: string }, K extends T["kind"]>(kind: K) {
  return (v: T): v is Extract<T, { kind: K }> => v.kind === kind;
}

const isText  = variantGuard<Msg, "text">("text");
const isImage = variantGuard<Msg, "image">("image");
const isVideo = variantGuard<Msg, "video">("video");

const messages: Msg[] = [
  { kind: "text",  content: "hi" },
  { kind: "image", url: "/a.png", alt: "a" },
  { kind: "video", url: "/b.mp4", durationSec: 12 },
];

const images = messages.filter(isImage);
console.log(images.map(m => m.url).join(", "));

Output:

text
/a.png

Recipe 8 — Schema-driven guard cache

A pattern for performance-critical hot paths: pre-compile guards once and reuse them. Useful in JSON-heavy hot loops (parsers, log shippers).

typescript
import { z } from "zod";

const schemas = {
  user:  z.object({ id: z.number(), name: z.string() }),
  post:  z.object({ id: z.number(), title: z.string() }),
  event: z.object({ ts: z.number(), kind: z.string() }),
} as const;

type Kind = keyof typeof schemas;

function isValid<K extends Kind>(kind: K, v: unknown): v is z.infer<(typeof schemas)[K]> {
  return schemas[kind].safeParse(v).success;
}

const inputs: unknown[] = [
  { id: 1, name: "Alice" },
  { id: 2, title: "Hello" },
  { ts: Date.now(), kind: "login" },
];

for (const item of inputs) {
  if (isValid("user", item))  console.log(`user:  ${item.name}`);
  if (isValid("post", item))  console.log(`post:  ${item.title}`);
  if (isValid("event", item)) console.log(`event: ${item.kind}`);
}

Output:

text
user:  Alice
post:  Hello
event: login