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.
Table of Contents
- 1. The problem with hand written type guards
- 2. Type predicates: the foundation of every type guard
- 3. Deriving a type guard directly from a Zod schema
- 4. A generic guard factory for any schema
- 5. Generating type guards for discriminated unions
- 6. Using assertion functions instead of type predicates
- 7. Filtering arrays and lists with generated guards
- 8. Limits of generated type guards and when to write manually
- 9. Manual vs. generated type guards compared
- 10. Summary
- 11. FAQ
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.