Web Development · TypeScript
Zod in Production TypeScript: Schema Validation Across the Full Stack
How Zod became the standard for runtime type validation in TypeScript apps — API contracts, form validation, environment variables, and the patterns that make schemas a source of truth across your stack.
Anurag Verma
7 min read
Sponsored
TypeScript catches type errors at compile time. That’s useful. But it does nothing at runtime — when HTTP request bodies, environment variables, and third-party API responses arrive as untyped data. Your TypeScript types are annotations on data you’ve already trusted.
Zod fills that gap. It’s a schema validation library that generates TypeScript types from schema definitions, so your compile-time types and runtime validation stay in sync automatically.
Why Zod Won
Before Zod, teams used joi, yup, or hand-rolled validation. Zod’s key insight was that validation schemas should generate TypeScript types, not the other way around.
import { z } from 'zod'
// Define the schema once
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1).max(100),
role: z.enum(['admin', 'user', 'viewer']),
createdAt: z.date(),
})
// TypeScript type is derived automatically
type User = z.infer<typeof UserSchema>
// {
// id: string;
// email: string;
// name: string;
// role: 'admin' | 'user' | 'viewer';
// createdAt: Date;
// }
No type duplication. Change the schema, the type changes. The alternative — maintaining a type definition and a validator separately — leads to drift.
Validating API Request Bodies
The most common use case: validating incoming request bodies in API routes.
Without validation, you’re doing this:
// No validation — trusting the caller
export async function POST(req: Request) {
const { email, name } = await req.json() // body is `any`
await createUser({ email, name }) // ¯\_(ツ)_/¯
}
With Zod:
const CreateUserBody = z.object({
email: z.string().email({ message: 'Invalid email address' }),
name: z.string().min(1, 'Name is required').max(100),
role: z.enum(['admin', 'user']).default('user'),
})
export async function POST(req: Request) {
const body = await req.json()
const result = CreateUserBody.safeParse(body)
if (!result.success) {
return Response.json(
{ error: 'Validation failed', details: result.error.flatten() },
{ status: 400 }
)
}
// result.data is fully typed as { email: string, name: string, role: 'admin' | 'user' }
const user = await createUser(result.data)
return Response.json(user)
}
safeParse returns either { success: true, data: T } or { success: false, error: ZodError }. Use it over parse in request handlers so you can return validation errors rather than throwing.
error.flatten() structures validation errors by field:
{
"fieldErrors": {
"email": ["Invalid email address"],
"name": ["Name is required"]
},
"formErrors": []
}
This maps directly to form field error display.
Environment Variable Validation
Undefined environment variables cause confusing runtime errors. Validate them at startup:
// lib/env.ts
import { z } from 'zod'
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
RESEND_API_KEY: z.string().startsWith('re_'),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
})
// Parse at module load time — fails fast if any required variable is missing
export const env = EnvSchema.parse(process.env)
Import env instead of process.env throughout your app:
import { env } from '@/lib/env'
const db = new PrismaClient({ datasourceUrl: env.DATABASE_URL })
Three benefits: missing variables fail at startup rather than mid-request, z.coerce.number() handles the string-to-number conversion that process.env always requires, and the type of env.PORT is number rather than string | undefined.
Form Validation with React Hook Form
Zod integrates directly with React Hook Form via the @hookform/resolvers package:
npm install react-hook-form @hookform/resolvers zod
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const SignupSchema = z.object({
email: z.string().email(),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: 'Passwords do not match',
path: ['confirmPassword'],
}
)
type SignupFormData = z.infer<typeof SignupSchema>
export function SignupForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SignupFormData>({
resolver: zodResolver(SignupSchema),
})
const onSubmit = (data: SignupFormData) => {
// data is typed and validated
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<input type="password" {...register('confirmPassword')} />
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
<button type="submit">Sign up</button>
</form>
)
}
The .refine() call adds cross-field validation. Here it checks that both password fields match and attaches the error to confirmPassword specifically.
Sharing Schemas Between Client and Server
In a full-stack TypeScript app, define schemas in a shared location and import them on both sides:
src/
schemas/
user.ts # User-related schemas
product.ts # Product-related schemas
auth.ts # Auth-related schemas
app/
api/
users/
route.ts # Uses UserSchema from schemas/user.ts
components/
forms/
CreateUserForm.tsx # Uses UserSchema from schemas/user.ts
When the API contract changes — say, role gets a new value moderator — update the schema in one place and both the API validation and the form validation update automatically.
Transforming Data
Zod schemas can transform data as part of parsing, not just validate it. This is useful for normalizing input:
const SearchQuerySchema = z.object({
q: z.string().trim().toLowerCase().min(1),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
tags: z.string()
.transform(s => s.split(',').map(t => t.trim()).filter(Boolean))
.optional(),
})
// Input: { q: " TypeScript ", page: "2", tags: "react, vue, angular" }
// Output: { q: "typescript", page: 2, limit: 20, tags: ["react", "vue", "angular"] }
z.coerce.number() converts string inputs to numbers — useful for URL query parameters which are always strings. .transform() runs arbitrary functions on the parsed value.
Parsing External API Responses
Don’t trust data from third-party APIs either. Their schema can change without notice.
const GitHubUserSchema = z.object({
login: z.string(),
id: z.number(),
name: z.string().nullable(),
email: z.string().email().nullable(),
public_repos: z.number(),
followers: z.number(),
})
async function getGitHubUser(username: string) {
const response = await fetch(`https://api.github.com/users/${username}`)
const data = await response.json()
// Will throw if GitHub changes their response shape
return GitHubUserSchema.parse(data)
}
This way, if GitHub adds a required field you depend on or changes a type, you find out at the parse step rather than when downstream code tries to use an undefined field.
Useful Patterns for APIs
Optional fields with defaults:
const PaginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(50).default(10),
sort: z.enum(['asc', 'desc']).default('desc'),
})
Union types for polymorphic inputs:
const NotificationSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('email'), address: z.string().email() }),
z.object({ type: z.literal('sms'), phone: z.string().regex(/^\+[1-9]\d{1,14}$/) }),
z.object({ type: z.literal('webhook'), url: z.string().url() }),
])
discriminatedUnion is more efficient than z.union() when the objects share a literal discriminator field.
Partial schemas for update operations:
const CreateProductSchema = z.object({
name: z.string().min(1),
price: z.number().positive(),
category: z.string(),
})
// For PATCH endpoints, every field is optional
const UpdateProductSchema = CreateProductSchema.partial()
Error Messages That Help Users
Zod’s default error messages are accurate but generic. Override them where users see them:
const PhoneSchema = z.string()
.regex(
/^\+[1-9]\d{1,14}$/,
'Enter a phone number in international format: +1234567890'
)
const PasswordSchema = z.string()
.min(8, 'Must be at least 8 characters')
.max(128, 'Maximum 128 characters')
Keep technical language out of user-facing messages. The regex pattern is for the developer; the plain-language description is for the user.
The Summary
Zod is most useful when you treat schemas as the source of truth for your data contracts. Define them once, infer TypeScript types from them, use them at every boundary where untyped data enters your system — HTTP bodies, environment variables, external API responses, URL parameters.
The upfront cost is writing schemas instead of types. The payoff is that your types stay honest, your runtime errors are caught early with actionable messages, and changes to data shape propagate to both validation and type checking automatically.
Sponsored
More from this category
More from Web Development
Sponsored
The dispatch
Working notes from
the studio.
A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored