z.email() vs. the Deprecated z.string().email() (Zod 4)
Zod 4 moved format validators — email, URL, UUID, IP, base64, JWT, and the ISO date/time/duration helpers — to top-level functions. The old method-chain forms (z.string().email(), .uuid(), .url()) still parse correctly but are deprecated: they type-check more slowly and don’t tree-shake as well as the top-level equivalents.
// deprecated, still works on Zod 4
const emailOld = z.string().email()
// preferred (Zod 4+)
const email = z.email()
const uuid = z.uuid()
const url = z.url()
const isoDate = z.iso.date()
const isoDatetime = z.iso.datetime()
// custom pattern, e.g. the stricter HTML5 email regex
const html5Email = z.email({ pattern: z.regexes.html5Email })
Every z.string().email() / .uuid() / .url() / .datetime() call elsewhere in this guide still runs correctly on Zod 4 — the deprecation is not a removal, and nothing here breaks. New code should reach for the top-level functions going forward. Migrating an existing Zod 3 codebase is mostly mechanical find-and-replace: z.string().email() → z.email(), z.string().uuid() → z.uuid(), and the same pattern for every other format validator.
Object Schemas
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email().toLowerCase(), // transform to lowercase
firstName: z.string().min(1).max(50),
lastName: z.string().min(1).max(50),
role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),
age: z.number().int().min(13).max(120).optional(),
createdAt: z.string().datetime(), // ISO 8601 string
})
type User = z.infer
// parse: throws ZodError on failure
const user = UserSchema.parse(rawData)
// safeParse: returns { success, data } or { success, error }
const result = UserSchema.safeParse(rawData)
if (!result.success) {
console.error(result.error.flatten())
// { fieldErrors: { email: ['Invalid email'] }, formErrors: [] }
}
Nested Objects and Arrays
const AddressSchema = z.object({
street: z.string().min(1),
city: z.string().min(1),
state: z.string().length(2),
zipCode: z.string().regex(/^d{5}(-d{4})?$/),
country: z.string().length(2).default('US'),
})
const OrderSchema = z.object({
id: z.string().uuid(),
customerId: z.string().uuid(),
items: z.array(
z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
unitPrice: z.number().positive(),
})
).min(1, 'Order must have at least one item'),
shippingAddress: AddressSchema,
total: z.number().positive(),
status: z.enum(['pending', 'confirmed', 'shipped', 'delivered', 'cancelled']),
notes: z.string().max(1000).nullable().default(null),
})
type Order = z.infer
// tuple with mixed types
const CoordSchema = z.tuple([z.number(), z.number()]) // [lat, lng]
const TripleSchema = z.tuple([z.string(), z.number(), z.boolean()])
Unions: z.union() vs. the .or() Shorthand
.or() is method-chain sugar for z.union() when you have two alternatives — handy when you’re already chaining off a schema and don’t want to break out of the fluent style.
const stringOrNumber = z.string().or(z.number())
// identical to:
const stringOrNumber2 = z.union([z.string(), z.number()])
// chains naturally with other methods
const idParam = z.coerce.number().or(z.string().uuid())
Reach for z.union() directly once you have three or more alternatives, and reach for z.discriminatedUnion() instead of either when the members are objects that share one distinguishing field — Zod can then pick the right branch by checking that single field instead of testing every schema in order:
const ApiResult = z.discriminatedUnion('success', [
z.object({ success: z.literal(true), data: z.unknown() }),
z.object({ success: z.literal(false), error: z.string() }),
])
// a plain union works too, but validates against every member until one matches
const ApiResultUnion = z.union([
z.object({ success: z.literal(true), data: z.unknown() }),
z.object({ success: z.literal(false), error: z.string() }),
])
Zod 4 also lets z.literal() take an array of values, which replaces the common pattern of unioning several literals together:
const httpCodes = z.literal([200, 201, 202, 204, 206, 207, 208, 226])
// Zod 3 equivalent: z.union([z.literal(200), z.literal(201), z.literal(202), ...])
// coerce strings from form inputs
const FormPriceSchema = z
.string()
.transform((val) => parseFloat(val))
.pipe(z.number().positive())
// parse ISO date strings into Date objects
const DateFromStringSchema = z
.string()
.datetime()
.transform((str) => new Date(str))
// normalize phone numbers
const PhoneSchema = z
.string()
.transform((val) => val.replace(/[^0-9]/g, ''))
.pipe(z.string().length(10, 'Must be 10 digits'))
// computed fields using transform
const FullNameSchema = z
.object({
firstName: z.string(),
lastName: z.string(),
})
.transform((data) => ({
...data,
fullName: `\${data.firstName} \${data.lastName}`,
initials: `\${data.firstName[0]}.\${data.lastName[0]}.`,
}))
const { firstName, lastName, fullName, initials } = FullNameSchema.parse({
firstName: 'Alex',
lastName: 'Sharma',
})
// fullName: 'WOWHOW', initials: 'A.K.'
z.infer is the type you get back from .parse() — that’s an alias for z.output. For a plain schema like z.string(), input and output are the same. Once a schema uses .transform(), they diverge: the input type is what you’re allowed to pass in, the output type is what you get back.
const FormPriceSchema = z
.string()
.transform((val) => parseFloat(val))
.pipe(z.number().positive())
type PriceInput = z.input // string
type PriceOutput = z.output // number
type PriceInferred = z.infer // number, same as z.output
// use z.input to type the raw data BEFORE validation
function handleFormSubmit(raw: z.input) {
const price = FormPriceSchema.parse(raw) // price is z.output: number
}
Get this backwards and you will type a function parameter with the post-transform shape (z.infer) when it actually receives the pre-transform shape — a common bug in form handlers and API clients, where the wire format (strings, ISO date strings) differs from the parsed format (numbers, Date objects). Rule of thumb: type what you receive with z.input, type what .parse() hands back with z.output or its z.infer alias.
Refinements: Cross-Field and Async Validation
// single-field refinement
const PasswordSchema = z
.string()
.min(8)
.refine(
(val) => /[A-Z]/.test(val) && /[0-9]/.test(val) && /[^a-zA-Z0-9]/.test(val),
{ message: 'Password must contain uppercase, number, and special character' }
)
// cross-field refinement
const PasswordConfirmSchema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'], // attach error to specific field
})
// date range validation
const DateRangeSchema = z
.object({
startDate: z.string().date(),
endDate: z.string().date(),
})
.refine(
(data) => new Date(data.endDate) > new Date(data.startDate),
{ message: 'End date must be after start date', path: ['endDate'] }
)
// async refinement (e.g. database uniqueness check)
const UniqueEmailSchema = z
.string()
.email()
.superRefine(async (email, ctx) => {
const exists = await db.user.findUnique({ where: { email } })
if (exists) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Email already in use',
})
}
})
// use parseAsync for schemas with async refinements
const validEmail = await UniqueEmailSchema.parseAsync('[email protected]')
API Request Validation in Next.js Route Handlers
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
const CreateProductSchema = z.object({
name: z.string().min(3).max(100),
price: z.number().positive(),
currency: z.enum(['USD', 'INR', 'EUR']).default('USD'),
description: z.string().min(10).max(5000),
tags: z.array(z.string()).max(10).default([]),
published: z.boolean().default(false),
})
type CreateProductInput = z.infer
export async function POST(req: NextRequest) {
let body: unknown
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const result = CreateProductSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
},
{ status: 422 }
)
}
const product = await createProduct(result.data)
return NextResponse.json(product, { status: 201 })
}
// helper to validate any route handler input
function validateBody(schema: z.ZodType) {
return async (req: NextRequest): Promise<{ data: T } | NextResponse> => {
let body: unknown
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const result = schema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Validation failed', details: result.error.flatten().fieldErrors },
{ status: 422 }
)
}
return { data: result.data }
}
}
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const CheckoutSchema = z.object({
email: z.string().email('Enter a valid email'),
firstName: z.string().min(1, 'Required').max(50),
lastName: z.string().min(1, 'Required').max(50),
address: z.string().min(5, 'Enter full address'),
city: z.string().min(1, 'Required'),
pinCode: z.string().regex(/^d{6}$/, 'Enter 6-digit PIN code'),
phone: z.string().regex(/^[6-9]d{9}$/, 'Enter valid Indian mobile number'),
})
type CheckoutFormData = z.infer
function CheckoutForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(CheckoutSchema),
})
const onSubmit = async (data: CheckoutFormData) => {
// data is fully typed AND validated
await submitOrder(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} placeholder="Email" />
{errors.email && <p>{errors.email.message}</p>}
<input {...register('pinCode')} placeholder="PIN Code" />
{errors.pinCode && <p>{errors.pinCode.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Processing...' : 'Place Order'}
</button>
</form>
)
}
Zod has no official Angular package, but bridging it into Reactive Forms is a small, reusable adapter: wrap safeParse in a ValidatorFn and attach it per control, the same way you’d attach any custom validator.
// zod-validator.ts — reusable Zod to Angular ValidatorFn bridge
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'
import { z } from 'zod'
function zodValidator(schema: z.ZodType): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const result = schema.safeParse(control.value)
if (result.success) return null
return { zod: result.error.flatten().formErrors.join(', ') }
}
}
// component
this.form = this.fb.group({
email: ['', [Validators.required, zodValidator(z.email())]],
pinCode: ['', [zodValidator(z.string().regex(/^d{6}$/, 'Enter 6-digit PIN code'))]],
})
For cross-field rules, run the schema against form.value as a group-level validator and map each Zod issue’s path back onto the matching control with control.setErrors(), mirroring what error.flatten().fieldErrors already gives you for free in the React Hook Form example above.
Angular v21+ Signal Forms remove the adapter entirely. Zod implements the community Standard Schema spec, so Signal Forms’ validateStandardSchema() accepts a Zod schema directly:
// Angular v21+ Signal Forms — no custom ValidatorFn needed
import { form, validateStandardSchema } from '@angular/forms/signals'
const userForm = form(userModel, (path) => {
validateStandardSchema(path, UserSchema)
})
Schema Composition and Reuse
// base schema — shared fields
const TimestampedSchema = z.object({
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
})
const BaseEntitySchema = z.object({
id: z.string().uuid(),
}).merge(TimestampedSchema)
// extend for specific entities
const ProductSchema = BaseEntitySchema.extend({
name: z.string(),
price: z.number().positive(),
})
// pick/omit for partial schemas
const ProductPreviewSchema = ProductSchema.pick({ id: true, name: true })
const CreateProductInput = ProductSchema.omit({ id: true, createdAt: true, updatedAt: true })
const UpdateProductInput = CreateProductInput.partial() // all fields optional
// discriminated unions
const ApiResponseSchema = z.discriminatedUnion('success', [
z.object({ success: z.literal(true), data: z.unknown() }),
z.object({ success: z.literal(false), error: z.string(), code: z.number() }),
])
type ApiResponse = z.infer
Branded Types: Nominal Typing with z.brand()
TypeScript’s type system is structural: a string holding a validated user ID and a string holding a validated product ID are interchangeable to the compiler, even though swapping them is a real bug. Zod’s .brand() attaches a nominal tag so the compiler rejects the mix-up. It costs nothing at runtime — the brand only exists in the type, not in the parsed value.
const UserId = z.string().uuid().brand<'UserId'>()
const ProductId = z.string().uuid().brand<'ProductId'>()
type UserId = z.infer
type ProductId = z.infer
function getUser(id: UserId) { /* ... */ }
const productId = ProductId.parse(someUuid)
getUser(productId)
// Type error: Argument of type 'ProductId' is not assignable to
// parameter of type 'UserId' — even though both are structurally 'string'.
const userId = UserId.parse(someOtherUuid)
getUser(userId) // fine
By default only the output is branded — the raw string you pass into .parse() stays a plain string, so code building the value doesn’t have to fight the type system. Zod 4.2+ exposes a second generic argument to control this directly:
z.string().brand<'Cat', 'out'>() // default — output branded, input plain
z.string().brand<'Cat', 'in'>() // input branded, output plain
z.string().brand<'Cat', 'inout'>() // both branded
Reach for branded types anywhere structural typing hides a real bug: money in different currencies, IDs across entity types, sanitized versus raw HTML strings, or validated versus unvalidated user input.
Environment Variable Validation
// src/env.ts — validate at startup, not at request time
import { z } from 'zod'
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
NEXTAUTH_SECRET: z.string().min(32),
RAZORPAY_KEY_ID: z.string().startsWith('rzp_'),
PORT: z.coerce.number().int().positive().default(3000),
})
const parsed = EnvSchema.safeParse(process.env)
if (!parsed.success) {
console.error('Invalid environment variables:')
console.error(parsed.error.flatten().fieldErrors)
process.exit(1)
}
export const env = parsed.data
// env.PORT is typed as number, env.DATABASE_URL is typed as string
People Also Ask
What is the difference between parse and safeParse in Zod?
parse throws a ZodError if validation fails. Use it when you want the error to propagate up as an exception — common in server-side code where you have a top-level error handler. safeParse returns a discriminated union { success: true, data } | { success: false, error }. Use it when you need to handle validation failure gracefully without exceptions — typical for API request validation and form handling where you want to return structured error messages.
Can Zod schemas generate OpenAPI / JSON Schema documentation?
Yes, via the zod-to-json-schema package (npm install zod-to-json-schema). Call zodToJsonSchema(MySchema) to get a JSON Schema object you can plug into Swagger UI or any OpenAPI toolchain. For full OpenAPI 3.x spec generation, @asteasolutions/zod-to-openapi provides a registry-based API that produces complete path definitions including request bodies, query params, and response schemas.
Use error.flatten() on the ZodError. It returns { fieldErrors: Record<string, string[]>, formErrors: string[] }. Field errors are keyed by the field path (e.g., { email: ['Invalid email address'] }). If you are using React Hook Form with zodResolver, this mapping happens automatically — the resolver converts Zod errors into React Hook Form’s error format and populates formState.errors for you.
Comments · 0
Beta: comments are stored locally on your device and not visible to other readers.
No comments yet. Be the first to share your thoughts.