patterntypescriptreactTip
Zod schema validation — composing schemas from primitives
Viewed 0 times
zodschemaz.infersafeParseparseruntime validationAPI boundarytype inference
Error Messages
Problem
Runtime validation and TypeScript types are often maintained separately, causing drift — the type says a field is a string but the API returns null, or a required field is missing. Zod unifies runtime validation and static typing into a single schema definition.
Solution
Build schemas from Zod primitives and use .parse() or .safeParse() at runtime boundaries:
import { z } from 'zod';
// Primitive schemas
const idSchema = z.number().int().positive();
const emailSchema = z.string().email();
const urlSchema = z.string().url().optional();
// Composed object schema
const userSchema = z.object({
id: idSchema,
email: emailSchema,
name: z.string().min(1).max(100),
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.string().datetime(),
website: urlSchema,
metadata: z.record(z.string(), z.unknown()).default({}),
});
type User = z.infer<typeof userSchema>;
// Safe parse — does not throw; returns { success, data } or { success, error }
async function getUser(id: number): Promise<User> {
const raw = await fetch(
const result = userSchema.safeParse(raw);
if (!result.success) {
console.error(result.error.flatten());
throw new Error('Invalid user data from API');
}
return result.data; // typed as User
}
// Hard parse — throws ZodError on failure
const user = userSchema.parse(rawData);
import { z } from 'zod';
// Primitive schemas
const idSchema = z.number().int().positive();
const emailSchema = z.string().email();
const urlSchema = z.string().url().optional();
// Composed object schema
const userSchema = z.object({
id: idSchema,
email: emailSchema,
name: z.string().min(1).max(100),
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.string().datetime(),
website: urlSchema,
metadata: z.record(z.string(), z.unknown()).default({}),
});
type User = z.infer<typeof userSchema>;
// Safe parse — does not throw; returns { success, data } or { success, error }
async function getUser(id: number): Promise<User> {
const raw = await fetch(
/api/users/${id}).then((r) => r.json());const result = userSchema.safeParse(raw);
if (!result.success) {
console.error(result.error.flatten());
throw new Error('Invalid user data from API');
}
return result.data; // typed as User
}
// Hard parse — throws ZodError on failure
const user = userSchema.parse(rawData);
Why
z.infer<typeof schema> derives the TypeScript type from the schema — you write it once and get both runtime validation and static typing. safeParse is preferred at API boundaries because it does not throw and gives structured error information.
Gotchas
- z.string() does not accept undefined — use z.string().optional() for optional string fields
- .optional() makes the field undefined; .nullable() makes it null; .nullish() accepts both
- z.object strips unknown keys by default (strict mode keeps them as unknown); use .passthrough() to preserve extra keys
- error.flatten() gives { fieldErrors, formErrors } — more useful than the raw ZodError for displaying validation messages
Revisions (0)
No revisions yet.