Generating Runtime Type Guards from Validation Schemas
AI generated
<T>
type
TypeScript · Type Guards · Zod · Narrowing
Generating Runtime Type Guards
From validation to a type safe check function

Hand written type guards with manual typeof and in checks drift apart easily whenever the underlying type changes, and they become a source of bugs in larger codebases. This article shows how to derive runtime type guards automatically from Zod schemas, with a generic guard factory, discriminated union guards, and a clear distinction between a type predicate and an assertion function.

12 min read Type predicates · asserts · safeParse Zod 3.x · TypeScript 5.x

1. The problem with hand written type guards

A classic type guard in TypeScript checks at runtime whether a value matches a certain structure, usually through a chain of typeof, in, and Array.isArray() calls. For a simple object with two fields this is quick to write, but for an object with nested structures, optional fields, and arrays, this check quickly grows into an unwieldy function that needs to be updated by hand whenever the type changes.

The real risk is not the length of the function, it is the missing coupling between the type and the guard. If an interface changes to include a new required field, the hand written type guard may not check that field at all, simply because nobody thought to update it. The compiler will not warn about this, because a type predicate function is accepted by TypeScript on trust, its actual checking logic is never verified against the claimed type.

2. Type predicates: the foundation of every type guard

A type predicate is a function whose return type has the shape value is X, instead of a plain boolean. This special signature tells the TypeScript compiler that a true return value narrows the type of the checked parameter in the calling code to X, an effect that an ordinary boolean return value does not trigger.

This narrowing only works within the same control flow, for example directly after an if that calls the type guard. Importantly, the compiler fully trusts the type predicate's signature, it does not verify whether the actual implementation really behaves as claimed. This exact trust relationship is why a guard function generated automatically from a schema is structurally safer than a hand written one.


// A hand-written type guard: signature promises narrowing, body must deliver it
interface Product {
  id: string;
  name: string;
  price: number;
}

function isProduct(value: unknown): value is Product {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value &&
    "price" in value &&
    typeof (value as Product).id === "string" &&
    typeof (value as Product).name === "string" &&
    typeof (value as Product).price === "number"
  );
}

function printProductName(value: unknown): void {
  if (isProduct(value)) {
    // value is narrowed to Product here, based on the predicate signature
    console.log(value.name);
  }
}

3. Deriving a type guard directly from a Zod schema

Instead of recreating a type guard's checking logic by hand, a Zod schema can be turned directly into a type guard by calling .safeParse() inside a function with the matching value is X signature. The TypeScript type X comes from z.infer<typeof schema>, so the schema, the type, and the guard are all derived from the same source and can never be maintained independently.

The practical advantage over the hand written guard from the previous section: if the schema changes, for example through an additional required field, the behavior of the generated is function changes automatically too, without a single line in the guard itself needing to be touched. The schema remains the only place in the code that actually gets maintained.


import { z } from "zod";

const productSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  price: z.number().positive(),
});

type Product = z.infer<typeof productSchema>;

// Type guard derived directly from the schema, no manual field checks
function isProduct(value: unknown): value is Product {
  return productSchema.safeParse(value).success;
}

function printProductName(value: unknown): void {
  if (isProduct(value)) {
    // Narrowed to Product, guaranteed to match productSchema exactly
    console.log(value.name);
  }
}

4. A generic guard factory for any schema

Instead of writing a separate is function by hand for every schema, a generic factory function can be defined that takes any Zod schema and returns a matching type guard. This guard factory works with a generic type parameter bound to the passed in schema, so TypeScript automatically infers the correct, specific return type for every call.

This pattern reduces boilerplate considerably, especially in codebases with dozens of domain objects. A new type no longer needs its own hand written guard, just a call to the factory with the matching schema, a one liner that is guaranteed to stay consistent with the validation logic.


import { z, type ZodType } from "zod";

// Generic factory: turns any Zod schema into a matching type guard
function createTypeGuard<T>(schema: ZodType<T>) {
  return (value: unknown): value is T => schema.safeParse(value).success;
}

const orderSchema = z.object({
  orderId: z.string().uuid(),
  total: z.number().positive(),
});

const userSchema = z.object({
  userId: z.string().uuid(),
  email: z.string().email(),
});

// One line per type, always in sync with the schema
const isOrder = createTypeGuard(orderSchema);
const isUser = createTypeGuard(userSchema);

function handleUnknownPayload(payload: unknown): void {
  if (isOrder(payload)) {
    console.log(`Order ${payload.orderId}, total ${payload.total}`);
  } else if (isUser(payload)) {
    console.log(`User ${payload.userId}, ${payload.email}`);
  }
}

5. Generating type guards for discriminated unions

The same principle can be applied to discriminated unions, though here a second, more specific guard per variant is often worthwhile, rather than a single guard for the entire union. A Zod discriminatedUnion schema exposes the individual sub schemas via .options, from which a dedicated, named type guard can be generated per variant, such as isCardCharged or isRefundIssued.

These per variant generated guards are particularly useful in code that does not branch through a central switch statement, but instead handles individual event types separately at various points in the application, for example independent event handlers that are each only interested in one specific variant.


import { z, type ZodType } from "zod";

const paymentEventSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("card_charged"), amount: z.number() }),
  z.object({ type: z.literal("refund_issued"), amount: z.number() }),
]);

function createTypeGuard<T>(schema: ZodType<T>) {
  return (value: unknown): value is T => schema.safeParse(value).success;
}

// Generate one focused guard per union variant from its sub-schema
const [cardChargedSchema, refundIssuedSchema] = paymentEventSchema.options;
const isCardCharged = createTypeGuard(cardChargedSchema);
const isRefundIssued = createTypeGuard(refundIssuedSchema);

function handleEvent(event: unknown): void {
  if (isCardCharged(event)) {
    console.log(`Charged: ${event.amount}`);
    return;
  }
  if (isRefundIssued(event)) {
    console.log(`Refunded: ${event.amount}`);
  }
}

6. Using assertion functions instead of type predicates

Besides type predicates, TypeScript also supports assertion functions with the signature asserts value is X. The difference: a type predicate returns a boolean that gets evaluated inside a condition, while an assertion function either returns normally or throws an exception, and the type is considered narrowed for the entire rest of the code path afterwards, without a surrounding if.

For generated guards derived from Zod schemas, the same factory can easily be extended with an assertion variant that internally uses .parse() instead of .safeParse() and throws on invalid data. This variant is particularly well suited for places in the code where invalid data represents a genuine programming error and an early crash with a clear message is more important than a silent, controlled rejection.


import { z, type ZodType } from "zod";

// Assertion function factory: throws instead of returning a boolean
function createAssertion<T>(schema: ZodType<T>) {
  return (value: unknown): asserts value is T => {
    const result = schema.safeParse(value);
    if (!result.success) {
      throw new Error(`Assertion failed: ${result.error.message}`);
    }
  };
}

const configSchema = z.object({
  apiUrl: z.string().url(),
  timeoutMs: z.number().positive(),
});

const assertValidConfig = createAssertion(configSchema);

function loadConfig(raw: unknown) {
  // No "if" needed: after this call, raw is narrowed for the rest of the function
  assertValidConfig(raw);
  return raw; // typed as the inferred config shape, not unknown
}

7. Filtering arrays and lists with generated guards

A common use case for generated type guards is filtering arrays with mixed or untrusted content, for example the result of JSON.parse() on a list of unknown objects. Array.prototype.filter() combined with a type predicate automatically narrows the resulting array type, TypeScript recognizes that only elements of the checked type remain after the filter, without any additional type cast.

This pattern is especially valuable when processing API responses with potentially inconsistent entries, for example when a feed contains some malformed records. Instead of discarding the entire response over one invalid entry, the generated guard filters out broken entries specifically and lets valid records continue to be processed with full type safety.


import { z, type ZodType } from "zod";

function createTypeGuard<T>(schema: ZodType<T>) {
  return (value: unknown): value is T => schema.safeParse(value).success;
}

const reviewSchema = z.object({
  rating: z.number().min(1).max(5),
  comment: z.string(),
});

const isReview = createTypeGuard(reviewSchema);

// Filter narrows the array type automatically, no cast needed afterwards
const rawEntries: unknown[] = JSON.parse(externalFeed);
const validReviews = rawEntries.filter(isReview);
const averageRating =
  validReviews.reduce((sum, r) => sum + r.rating, 0) / validReviews.length;

8. Limits of generated type guards and when to write manually

Generated type guards cover the vast majority of cases, but they hit their limits when a check needs more context than the schema itself provides, for example a cross object rule that checks a value against the current application state instead of just its own structure. In these cases, a manually written guard that accepts additional parameters remains the more pragmatic solution.

Another edge case is performance in hot paths with a very high call frequency, such as a render loop. safeParse() builds a complete result object with potential error details on every call, which is measurably more expensive than a lean, hand written typeof check. For such spots, it is worth making a deliberate trade off between maintainability and raw performance instead of applying the generated pattern everywhere unreflectively.

9. Manual vs. generated type guards compared

The following overview compares both approaches along the criteria that most often decide in practice.

Criterion Manual type guard Generated type guard
Sync with the type Must be updated by hand when the type changes Automatically in sync, derived from the same schema
Boilerplate per type Own function with full checking logic One call to the guard factory
Performance per call Very fast, minimal check Slightly slower due to full result object
Cross object rules Freely extendable with extra parameters Only structural check without external context
Source of error when forgotten Silent bug, compiler does not warn Ruled out, since the guard depends directly on the schema

For the vast majority of domain objects in an application, the generated approach is the more robust choice, while individual, performance critical, or context dependent guards should still be written by hand deliberately.

Mironsoft

Type guards, guard factories and runtime validation for your TypeScript codebase

Stop maintaining type guards by hand?

We build a generic guard factory for your domain objects, generate discriminated union guards, and set up assertion functions at the critical points in your code.

Guard audit

Checking existing hand written type guards for drift

Guard factory

Introducing generic derivation from existing Zod schemas

Performance tuning

Identifying hot paths and optimizing them manually where it counts

10. Summary

Runtime type guards are an area where manual maintenance especially easily leads to silent bugs, because the compiler trusts a type predicate's signature without checking the actual implementation against the claimed type. Generating type guards directly from Zod schemas closes this gap structurally, since the schema, the type, and the guard are all derived from the same source and can never drift apart independently.

A generic guard factory reduces boilerplate across dozens of domain objects to a single function call per type, while discriminated union guards and assertion functions can be used deliberately for special cases such as event handling or early program termination. Only in hot paths with extremely high call frequency or with cross object rules does a hand written guard remain the more pragmatic solution.

Generating Runtime Type Guards, the key points at a glance

Understanding type predicates

value is X narrows the type, the compiler trusts the signature without checking it.

Guard factory

A generic wrapper around safeParse() covers any Zod schema.

Discriminated unions and arrays

Generate guards per variant, filter() narrows array types automatically.

Know the limits

Hot paths and cross object rules still justify manual guards.

11. FAQ: Generating Runtime Type Guards

1Difference between a type guard and a type predicate?
Type guard is the function, type predicate is the special return signature value is X that signals narrowing to the compiler.
2Why are hand written guards error prone?
The compiler trusts the signature without checking it. If the type changes but the guard does not, a silent bug appears with no compiler warning.
3How to generate a guard from a Zod schema?
A function with value is T calls schema.safeParse(value).success, T comes from z.infer. Schema, type and guard from one source.
4What does a generic guard factory do?
Takes any schema and returns a matching is function, one call instead of a fully rewritten guard function.
5Generating guards for discriminated union variants?
Extract the sub schemas via the options array and turn each into its own named guard with the same factory.
6Difference between type predicate and assertion function?
Predicate returns boolean, assertion function with asserts value is X throws on invalid data, type is narrowed afterwards without if.
7Using generated guards to filter arrays?
filter() recognizes a type predicate automatically and narrows the array type, invalid entries get removed in a type safe way.
8When to still write manually despite a factory?
For cross object rules with external context and in hot paths with very high call frequency, where safeParse becomes measurably significant.
9Are generated guards slower?
Slightly, since safeParse builds a full result object. Negligible for most cases, measurable in hot paths.
10Does this also work with Valibot?
Yes, library agnostic. The factory only needs to replace safeParse with v.safeParse and evaluate the result fields accordingly.