cheat sheet
Zod
TypeScript-first runtime validation library. Define schemas, validate inputs, infer static types — works at API boundaries, env config, forms, and tRPC.
Zod — TypeScript-first schema validation with static type inference
What it is
Zod is a TypeScript-first schema declaration and validation library. You define a schema (z.object({ ... })), and Zod gives you (a) a runtime validator (schema.parse(input)) and (b) a statically-inferred TypeScript type (z.infer<typeof schema>). The two are guaranteed to stay in sync because they come from the same source — eliminating the duplicated "TypeScript interface + manual validator" pattern that plagued early TS codebases. It's the standard validator for tRPC, t3-env, and React Hook Form integrations, and a default at API boundaries in Fastify, Hono, and Next.js apps. Alternatives include Valibot (smaller bundle, modular), ArkType (faster, TS-syntax-like), and Yup (older, less type-safe).
Install
# npm
npm install zod
# pnpm
pnpm add zod
# yarn
yarn add zod
# bun
bun add zod
Output: (none — exits 0 on success)
Zod requires TypeScript ≥4.5 and strict: true in tsconfig.json to give you the full type inference experience:
{
"compilerOptions": {
"strict": true,
"target": "es2020",
"module": "esnext",
"moduleResolution": "bundler"
}
}
Syntax
A Zod schema is a value of type ZodType<T>. You build it with the z factory, then call .parse(input) to validate (throws on failure) or .safeParse(input) to validate without throwing.
import { z } from "zod";
const Schema = z.object({ /* ... */ });
const result = Schema.parse(input); // throws ZodError on failure
const safe = Schema.safeParse(input); // { success, data | error }
type Inferred = z.infer<typeof Schema>; // static TS type
Output: (none — exits 0 on success)
Primitive schemas
The primitive builders (z.string(), z.number(), z.boolean(), z.date(), z.bigint(), z.symbol()) match their JS type and chain validation methods like .min(), .max(), .email(), .url(), .uuid(), .regex(). Each chained call returns a new schema, so order is irrelevant (except .optional()/.nullable() which wrap).
import { z } from "zod";
// Strings
const Email = z.string().email();
const Url = z.string().url();
const Slug = z.string().min(1).max(64).regex(/^[a-z0-9-]+$/);
const Uuid = z.string().uuid();
const NonEmpty = z.string().trim().min(1, "cannot be empty");
// Numbers
const Port = z.number().int().min(1).max(65_535);
const Price = z.number().positive().multipleOf(0.01);
const Percent = z.number().min(0).max(100);
// Booleans, dates, bigints
const Flag = z.boolean();
const Born = z.date().min(new Date("1900-01-01"));
const Big = z.bigint().positive();
// Literals (exact match)
const Method = z.literal("GET");
const StatusOk = z.literal(200);
// Enums
const Role = z.enum(["admin", "editor", "viewer"]);
type Role = z.infer<typeof Role>; // "admin" | "editor" | "viewer"
// Native enum (TS enum)
enum Status { Active = "active", Banned = "banned" }
const StatusSchema = z.nativeEnum(Status);
Email.parse("alice@example.com"); // "alice@example.com"
Email.parse("not-an-email"); // throws ZodError
Output:
ZodError: [
{
"validation": "email",
"code": "invalid_string",
"message": "Invalid email",
"path": []
}
]
Objects, arrays, records, tuples
z.object({ ... }) is the workhorse. By default Zod strips unknown keys (rather than failing or passing them through) — that behaviour is controlled by .strict(), .strip(), and .passthrough().
const User = z.object({
id: z.number().int().positive(),
email: z.string().email(),
name: z.string().min(1),
role: z.enum(["admin", "user"]),
tags: z.array(z.string()).default([]),
createdAt: z.date(),
});
type User = z.infer<typeof User>;
// {
// id: number;
// email: string;
// name: string;
// role: "admin" | "user";
// tags: string[];
// createdAt: Date;
// }
// Arrays
const Names = z.array(z.string()).min(1).max(10);
const Pairs = z.array(z.tuple([z.string(), z.number()]));
// Records — homogeneous keyed objects (Record<K, V>)
const Counters = z.record(z.string(), z.number());
// Tuples — heterogeneous, fixed length
const Coord = z.tuple([z.number(), z.number(), z.number()]);
// Strict — fail on unknown keys
const StrictUser = User.strict();
StrictUser.parse({ id: 1, email: "a@b.c", name: "Alice", role: "user",
tags: [], createdAt: new Date(), extra: "boom" });
// throws — "Unrecognized key: 'extra'"
// Passthrough — keep unknown keys
const Loose = User.passthrough();
Output:
ZodError: [
{
"code": "unrecognized_keys",
"keys": ["extra"],
"path": [],
"message": "Unrecognized key: 'extra'"
}
]
Optional, nullable, and defaults
.optional() wraps a schema as T | undefined, .nullable() as T | null, and .nullish() as T | null | undefined. .default(value) provides a fallback when the input is undefined and changes the output type to non-optional (the input type stays optional).
const Profile = z.object({
bio: z.string().optional(), // string | undefined
avatar: z.string().url().nullable(), // string | null
nickname: z.string().nullish(), // string | null | undefined
joinedAt: z.date().default(() => new Date()),
newsletter: z.boolean().default(false),
});
type ProfileIn = z.input<typeof Profile>;
// { bio?: string; avatar: string | null; nickname?: string | null;
// joinedAt?: Date; newsletter?: boolean }
type ProfileOut = z.infer<typeof Profile>;
// { bio?: string; avatar: string | null; nickname?: string | null;
// joinedAt: Date; newsletter: boolean } <-- defaults applied
Profile.parse({ avatar: null });
// { avatar: null, joinedAt: <now>, newsletter: false }
Output:
{ avatar: null, joinedAt: 2026-05-25T10:00:00.000Z, newsletter: false }
parse vs safeParse
parse throws a ZodError on failure and returns the validated value on success. safeParse never throws — it returns a discriminated union { success: true, data } | { success: false, error }. Use safeParse at trust boundaries (HTTP handlers, form submissions, IPC) where you want to return a 400 with diagnostics rather than crash; use parse for trusted/internal data where a failure is a bug.
const User = z.object({ id: z.number(), email: z.string().email() });
// parse: throws on failure
try {
const user = User.parse(JSON.parse(req.body));
// user is fully typed + validated
} catch (err) {
if (err instanceof z.ZodError) {
console.error(err.issues);
}
}
// safeParse: discriminated union, no try/catch
const result = User.safeParse(JSON.parse(req.body));
if (!result.success) {
return res.status(400).json({ errors: result.error.issues });
}
console.log(result.data); // fully typed
Output (safeParse failure):
{
success: false,
error: ZodError {
issues: [
{ code: 'invalid_type', expected: 'number', received: 'string',
path: ['id'], message: 'Expected number, received string' },
{ code: 'invalid_string', validation: 'email',
path: ['email'], message: 'Invalid email' }
]
}
}
There's also parseAsync / safeParseAsync for schemas that contain async refinements (e.g. database lookups inside a .refine).
Unions and discriminated unions
z.union([...]) matches any of N schemas (tries them in order). For tagged objects, prefer z.discriminatedUnion("kind", [...]) — it's faster (Zod picks the right branch by reading the discriminant key) and produces vastly better error messages.
// Plain union — tries each branch in order
const StringOrNumber = z.union([z.string(), z.number()]);
// Discriminated union — branch picked by the literal field
const Shape = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("circle"), radius: z.number() }),
z.object({ kind: z.literal("square"), side: z.number() }),
z.object({ kind: z.literal("rectangle"), w: z.number(), h: z.number() }),
]);
type Shape = z.infer<typeof Shape>;
// | { kind: "circle"; radius: number }
// | { kind: "square"; side: number }
// | { kind: "rectangle"; w: number; h: number }
const s = Shape.parse({ kind: "circle", radius: 5 });
if (s.kind === "circle") {
// TypeScript narrows automatically
console.log(s.radius);
}
Shape.parse({ kind: "triangle", base: 3, height: 4 });
// throws — "Invalid discriminator value. Expected 'circle' | 'square' | 'rectangle'"
Output:
ZodError: [
{
"code": "invalid_union_discriminator",
"options": ["circle", "square", "rectangle"],
"path": ["kind"],
"message": "Invalid discriminator value. Expected 'circle' | 'square' | 'rectangle'"
}
]
Transformations and refinements
.transform(fn) post-processes a validated value into a new shape — the output type changes, the input stays the same. .refine(predicate, message) adds an arbitrary boolean check that produces a custom error message. .superRefine is the multi-issue variant for cases where one input may produce multiple errors.
// Transform: input string, output Date
const DateFromString = z.string()
.datetime()
.transform((s) => new Date(s));
type In = z.input<typeof DateFromString>; // string
type Out = z.infer<typeof DateFromString>; // Date
DateFromString.parse("2026-05-25T10:00:00Z"); // Date object
// Refine: custom validation
const Password = z.string()
.min(8, "at least 8 chars")
.refine((s) => /[A-Z]/.test(s), "needs an uppercase letter")
.refine((s) => /[0-9]/.test(s), "needs a digit");
// Refine across multiple fields — at the object level
const Signup = z.object({
password: z.string().min(8),
confirm: z.string(),
}).refine((d) => d.password === d.confirm, {
message: "passwords don't match",
path: ["confirm"], // attach the error to a specific field
});
// SuperRefine for multiple issues
const Username = z.string().superRefine((val, ctx) => {
if (val.length < 3) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "too short" });
}
if (val.includes(" ")) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "no spaces" });
}
});
// Async refinement — DB uniqueness check
const UniqueEmail = z.string().email().refine(
async (email) => !(await db.user.findUnique({ where: { email } })),
{ message: "email already taken" },
);
// Use parseAsync for schemas with async refines
await UniqueEmail.parseAsync("alice@example.com");
Output: (none — exits 0 on success)
Coercion — z.coerce
z.coerce.<type>() runs the JS coercion function (String(), Number(), Boolean(), new Date(), BigInt()) before validation. Indispensable for URL params, env vars, and form inputs that arrive as strings.
const Port = z.coerce.number().int().min(1).max(65_535);
Port.parse("3000"); // 3000 (number)
Port.parse(3000); // 3000
const When = z.coerce.date();
When.parse("2026-05-25"); // Date object
const Flag = z.coerce.boolean();
Flag.parse("true"); // true
Flag.parse("false"); // true (!! — coerce is just `Boolean()`)
Flag.parse(""); // false
Flag.parse(0); // false
z.coerce.boolean()callsBoolean(x), which is "truthy" — the string"false"becomestruebecause non-empty strings are truthy in JS. For env-var booleans, use a transform instead:z.enum(["true", "false"]).transform(v => v === "true")orz.string().transform((s) => s === "true" || s === "1").
Extracting types — z.infer and z.input
z.infer<typeof S> gives the output type (post-transform, post-default). z.input<typeof S> gives the input type (what you pass to parse). For schemas with no transforms/defaults, the two are identical.
const Schema = z.object({
count: z.coerce.number(),
createdAt: z.date().default(() => new Date()),
raw: z.string().transform((s) => s.toUpperCase()),
});
type SchemaIn = z.input<typeof Schema>;
// { count: unknown; createdAt?: Date; raw: string }
type SchemaOut = z.infer<typeof Schema>;
// { count: number; createdAt: Date; raw: string }
Composing schemas — extend, pick, omit, merge, partial, deepPartial
Object schemas are first-class values: you can .extend, .pick, .omit, .merge, .partial(), and .deepPartial() them. This is the Zod equivalent of TS Pick/Omit/Partial utility types — but at runtime.
const User = z.object({
id: z.number(),
email: z.string().email(),
name: z.string(),
password: z.string(),
createdAt: z.date(),
});
// Pick — narrow to a subset of fields
const PublicUser = User.pick({ id: true, email: true, name: true });
// Omit — drop fields
const SafeUser = User.omit({ password: true });
// Extend — add fields
const Admin = User.extend({ permissions: z.array(z.string()) });
// Merge — combine two object schemas
const Timestamped = z.object({ updatedAt: z.date() });
const UserWithUpdated = User.merge(Timestamped);
// Partial — all fields optional (useful for PATCH bodies)
const UserPatch = User.partial();
// Deep partial — recursive
const DeepPatch = User.deepPartial();
// Required — make optional fields required
const NoOptionals = UserPatch.required();
Output: (none — exits 0 on success)
Error handling and formatting
A ZodError exposes an issues array — one entry per validation failure with code, path, and message. Use error.format() for a nested object keyed by field path (handy for forms), or error.flatten() for a flat { fieldErrors, formErrors } shape.
const Form = z.object({
email: z.string().email(),
age: z.number().int().min(18),
address: z.object({
zip: z.string().regex(/^\d{5}$/),
}),
});
const result = Form.safeParse({ email: "x", age: 12, address: { zip: "bad" } });
if (!result.success) {
console.log(result.error.issues);
console.log(result.error.flatten());
console.log(result.error.format());
}
Output:
[
{ code: 'invalid_string', validation: 'email', path: ['email'], message: 'Invalid email' },
{ code: 'too_small', minimum: 18, path: ['age'], message: 'Number must be greater than or equal to 18' },
{ code: 'invalid_string', validation: 'regex', path: ['address','zip'], message: 'Invalid' }
]
{
formErrors: [],
fieldErrors: {
email: ['Invalid email'],
age: ['Number must be greater than or equal to 18'],
address: ['Invalid input']
}
}
{
_errors: [],
email: { _errors: ['Invalid email'] },
age: { _errors: ['Number must be greater than or equal to 18'] },
address: { _errors: [], zip: { _errors: ['Invalid'] } }
}
Custom messages are passed as the second arg to most builders or via .refine(predicate, message):
const Email = z.string({
required_error: "Email is required",
invalid_type_error: "Email must be a string",
}).email("That's not a valid email address.");
Validating environment variables
The single highest-leverage place to use Zod is at process startup: parse process.env into a typed env object and crash early if anything is missing or malformed. The standard pattern combines Zod with dotenv.
// src/env.ts
import { z } from "zod";
import "dotenv/config"; // load .env into process.env
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().min(1).max(65_535).default(3000),
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(32),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
// Booleans need a transform, not coerce (see warning above)
FEATURE_BETA: z.string()
.default("false")
.transform((v) => v === "true" || v === "1"),
});
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error("❌ Invalid environment:");
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const env = parsed.data;
export type Env = z.infer<typeof EnvSchema>;
// src/server.ts
import { env } from "./env";
console.log(`Starting in ${env.NODE_ENV} on port ${env.PORT}`);
// env.PORT is `number`, env.FEATURE_BETA is `boolean`
Run with a bad .env:
DATABASE_URL=not-a-url PORT=abc node --import tsx src/server.ts
Output:
❌ Invalid environment:
{
DATABASE_URL: [ 'Invalid url' ],
PORT: [ 'Expected number, received nan' ],
API_KEY: [ 'Required' ]
}
Framework integrations
Fastify — schema-driven JSON validation
Fastify natively supports JSON-Schema validators on routes. Use fastify-type-provider-zod to plug Zod schemas in and get end-to-end types from request to handler.
npm install fastify fastify-type-provider-zod zod
Output: (none — exits 0 on success)
import Fastify from "fastify";
import { z } from "zod";
import {
serializerCompiler,
validatorCompiler,
ZodTypeProvider,
} from "fastify-type-provider-zod";
const app = Fastify().withTypeProvider<ZodTypeProvider>();
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
app.post("/users", {
schema: {
body: z.object({
email: z.string().email(),
name: z.string().min(1),
}),
response: {
201: z.object({ id: z.number(), email: z.string().email() }),
},
},
handler: async (req, reply) => {
// req.body is fully typed: { email: string; name: string }
const user = await createUser(req.body);
return reply.code(201).send(user);
},
});
tRPC — end-to-end typed RPC
tRPC uses Zod for input validation; the inferred type flows from the schema into both the server handler and the client call.
import { z } from "zod";
import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
export const appRouter = t.router({
createUser: t.procedure
.input(z.object({
email: z.string().email(),
age: z.number().int().min(18),
}))
.mutation(async ({ input }) => {
// input typed { email: string; age: number }
return createUser(input);
}),
});
export type AppRouter = typeof appRouter;
// client side
const user = await trpc.createUser.mutate({
email: "alice@example.com",
age: 30,
}); // fully typed; bad inputs are caught at compile time
React Hook Form
@hookform/resolvers/zod plugs a Zod schema in as the form's validator.
npm install react-hook-form @hookform/resolvers zod
Output: (none — exits 0 on success)
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const Schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
type FormValues = z.infer<typeof Schema>;
function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(Schema),
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register("password")} />
{errors.password && <p>{errors.password.message}</p>}
<button type="submit">Sign in</button>
</form>
);
}
Comparison with other validators
| Aspect | Zod | Valibot | ArkType | Yup |
|---|---|---|---|---|
| API style | Method chaining | Modular functions (tree-shakeable) | TS-syntax-like strings | Method chaining |
| Bundle size (gzipped) | ~13 KB | ~1-3 KB (per-schema) | ~10 KB | ~17 KB |
| Static inference | First-class | First-class | First-class | Partial |
| Runtime speed | Baseline | Comparable | 1-100× faster (compiled) | Slower |
| Ecosystem | Massive (default in tRPC, t3) | Growing | New | Older, less used |
| TS version required | ≥4.5 | ≥5.0 | ≥5.1 | ≥4.0 |
| Async refinements | Yes | Yes | No (planned) | Yes |
| Stable since | 2020 | 2024 | 2024 | 2014 |
Zod is the safe default — most TS jobs in 2026 expect familiarity with it. If your app is bundle-size critical (e.g. an embedded widget) and you need only a small subset of validators, Valibot's tree-shakeable design will be noticeably smaller. If you need pure performance at scale (millions of validations per second), ArkType compiles schemas to specialised JS for big wins.
Common pitfalls
- Confusing
z.inputandz.infer— for schemas with defaults or transforms they differ. Usez.inputfor the type you pass toparse,z.infer(alias ofz.output) for what you get back. z.coerce.boolean()for env vars — see the warning above."false"becomestrue. Use a transform:z.string().transform((v) => v === "true")..parsein HTTP handlers — throws on bad input, which (without a global error handler) leaks a stack trace. UsesafeParseand return a structured 400 response.- Forgetting
await parseAsyncfor async refines — calling.parseon a schema that contains an async.refinethrows synchronously. Always useparseAsync/safeParseAsyncwhen async refinements are present. - Cyclic schemas with
z.object({ children: z.array(z.lazy(() => …)) })— TS can't infer recursive types automatically. Annotate explicitly:const Tree: z.ZodType<TreeType> = z.lazy(() => …). - Strict mode strips unknown keys silently — by default,
z.object({}).parse({ extra: 1 })succeeds and returns{}. If you need to reject unknowns, append.strict(). If you need to preserve them, use.passthrough(). - Reusing one schema across input and output — a schema with
.transformproduces a different output type. For DTOs, define the input schema and letz.inferproduce both types from the same source rather than duplicating. - Validating large blobs synchronously on a hot path — Zod is fast but not free. For Fastify routes, use
fastify-type-provider-zodso validation is JIT-compiled into the route lifecycle rather than re-running per request. - Treating Zod as a TS type-checker replacement — it's a runtime validator. Compile-time TS types still need to be there; Zod augments them at runtime. Don't
.parse(x as any)to silence TS — fix the type first. - Forgetting
strict: trueintsconfig.json— without strict mode, optional/nullable inference falls back to looser types and you lose half the value.
Real-world recipes
Parse + validate an env config at startup (with dotenv)
The single most-used Zod recipe — fail fast, get a typed env object, never re-validate again.
npm install zod dotenv
Output: (none — exits 0 on success)
// src/env.ts
import { z } from "zod";
import "dotenv/config";
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
PORT: z.coerce.number().int().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string().min(32),
STRIPE_KEY: z.string().startsWith("sk_"),
SENTRY_DSN: z.string().url().optional(),
});
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error("Invalid env:", parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const env = parsed.data;
node --import tsx src/server.ts
Output:
Invalid env: {
DATABASE_URL: [ 'Required' ],
JWT_SECRET: [ 'String must contain at least 32 character(s)' ]
}
Validate JSON from fetch
A typed wrapper around fetch that validates the response body and returns typed data — no more as any at API boundaries.
import { z } from "zod";
async function fetchJson<S extends z.ZodTypeAny>(
url: string,
schema: S,
init?: RequestInit,
): Promise<z.infer<S>> {
const res = await fetch(url, init);
if (!res.ok) throw new Error(`${url} → ${res.status}`);
const json = await res.json();
return schema.parse(json);
}
// Usage
const UserSchema = z.object({
id: z.number(),
login: z.string(),
name: z.string().nullable(),
});
const user = await fetchJson("https://api.github.com/users/alicedev", UserSchema);
console.log(user.login); // typed as string
Output:
alicedev
Discriminated union for an event stream
A WebSocket / SSE event handler that narrows on the type field automatically.
import { z } from "zod";
const Event = z.discriminatedUnion("type", [
z.object({ type: z.literal("chat"), userId: z.number(), text: z.string() }),
z.object({ type: z.literal("join"), userId: z.number(), name: z.string() }),
z.object({ type: z.literal("leave"), userId: z.number() }),
z.object({ type: z.literal("typing"), userId: z.number(), at: z.coerce.date() }),
]);
type Event = z.infer<typeof Event>;
socket.onmessage = (raw) => {
const result = Event.safeParse(JSON.parse(raw.data));
if (!result.success) {
console.warn("Dropping malformed event", result.error.issues);
return;
}
const ev = result.data;
switch (ev.type) {
case "chat": render(`${ev.userId}: ${ev.text}`); break;
case "join": render(`${ev.name} joined`); break;
case "leave": render(`${ev.userId} left`); break;
case "typing": showTypingIndicator(ev.userId, ev.at); break;
}
};
A PATCH endpoint with partial schemas
Reuse one canonical schema for create + update by deriving the PATCH body from .partial().
import { z } from "zod";
const User = z.object({
email: z.string().email(),
name: z.string().min(1),
age: z.number().int().min(0).max(150),
});
const CreateUser = User;
const UpdateUser = User.partial(); // every field optional
// POST /users — full body required
app.post("/users", async (req, res) => {
const body = CreateUser.parse(req.body);
res.json(await db.user.create({ data: body }));
});
// PATCH /users/:id — any subset
app.patch("/users/:id", async (req, res) => {
const body = UpdateUser.parse(req.body);
if (Object.keys(body).length === 0) {
return res.status(400).json({ error: "no fields to update" });
}
res.json(await db.user.update({ where: { id: +req.params.id }, data: body }));
});
Output: (none — exits 0 on success)
Form validation with custom messages
Per-field, per-rule error messages for a signup form.
const Signup = z.object({
email: z.string({ required_error: "Email is required" })
.email("That's not a valid email address"),
password: z.string()
.min(8, "At least 8 characters")
.regex(/[A-Z]/, "Needs an uppercase letter")
.regex(/[0-9]/, "Needs a digit"),
confirm: z.string(),
acceptTerms: z.literal(true, {
errorMap: () => ({ message: "You must accept the terms" }),
}),
}).refine((d) => d.password === d.confirm, {
message: "Passwords don't match",
path: ["confirm"],
});
const result = Signup.safeParse({
email: "x",
password: "abc",
confirm: "abd",
acceptTerms: false,
});
if (!result.success) {
console.log(result.error.flatten().fieldErrors);
}
Output:
{
email: [ "That's not a valid email address" ],
password: [ 'At least 8 characters', 'Needs an uppercase letter', 'Needs a digit' ],
confirm: [ "Passwords don't match" ],
acceptTerms: [ 'You must accept the terms' ]
}
Generating JSON Schema for OpenAPI
zod-to-json-schema converts a Zod schema to a JSON-Schema document — useful for OpenAPI generation, public-facing API docs, or feeding to LLMs that expect JSON-Schema tool definitions.
npm install zod-to-json-schema
Output: (none — exits 0 on success)
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
const User = z.object({
id: z.number().int().positive(),
email: z.string().email(),
role: z.enum(["admin", "user"]),
});
console.log(JSON.stringify(zodToJsonSchema(User, "User"), null, 2));
Output:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$ref": "#/definitions/User",
"definitions": {
"User": {
"type": "object",
"properties": {
"id": { "type": "integer", "exclusiveMinimum": 0 },
"email": { "type": "string", "format": "email" },
"role": { "type": "string", "enum": ["admin", "user"] }
},
"required": ["id", "email", "role"],
"additionalProperties": false
}
}
}