Schema Validation with Zod: Stop Trusting Your API Inputs
If you're shipping a TypeScript SaaS in 2026 without runtime validation at API boundaries, you have bugs in production today that you don't know about. The TypeScript compiler trusts the types you write; the runtime trusts whatever JSON shows up in a request body. Without validation, a missing field becomes undefined deep in business logic; a malformed date becomes a NaN; an injection payload reaches your DB. Most indie SaaS ships "we'll add validation later" and never does. The fix in 2026 isn't manual if (!body.email) return 400 — it's Zod (or Valibot / TypeBox / ArkType) for declarative schemas with auto-typed outputs.
A working validation strategy answers: where to validate (every external boundary), which library (Zod is default; Valibot for bundle size; TypeBox for JSON-Schema interop), how to derive types from schemas (one source of truth), how to handle errors (user-friendly messages, not stack traces), how to share schemas (frontend + backend), and how to test (snapshot the failure cases).
This guide is the implementation playbook for runtime validation. Companion to API Versioning, API Pagination Patterns, Inbound Webhooks, Public API, and Logging Strategy & Structured Logs.
Why Validation Matters
Get the failure modes clear first.
Help me understand validation failures.
The categories:
**1. Missing fields**
TS says `body.email: string` but JSON didn't have email.
TS compiler can't catch this; runtime crashes downstream.
**2. Wrong types**
TS says `body.age: number` but request sent `"42"` (string).
JavaScript silently coerces; comparison fails subtly later.
**3. Invalid values**
Email field contains "not-an-email"; URL field contains "javascript:alert(1)".
Type-checks pass; semantic problems later.
**4. Out-of-range**
Number that should be 1-100 receives 999999999.
Database insertion succeeds; UI renders wrong.
**5. Extra fields (potential injection)**
Body includes `{ ...legitFields, isAdmin: true }`.
Naive code spreads to DB, escalates privilege.
**6. Type confusion (especially Date)**
ISO string vs Date object vs Unix epoch — silently mismatched.
**7. JSON injection**
JSON arrays where objects expected.
Crashes; or worse, silently does the wrong thing.
**8. Missing-but-required nested**
body.user.email passes "user" check but inner email missing.
Nullable chains failure deep in code.
The boundaries to validate:
- Every API request body
- Every API query parameter
- Every webhook payload (third-party data)
- Every database read with JSON / unstructured columns
- Every form submission
- Every CSV / file upload row
- Every environment variable at boot
- Every response from external API (yes, validate INCOMING data even from "trusted" APIs)
Untrusted inputs are EVERYTHING outside your function.
For my app:
- API surface inventory
- Worst-case missing-validation scenario
- Compliance requirements
Output:
1. Validation surface
2. Risk priorities
3. Implementation order
The biggest unforced error: trusting TypeScript types at runtime. TS exists at compile-time only. The user, the network, the third-party API don't read your .ts files. Every external boundary needs runtime validation.
Why Zod (and the 2026 Alternatives)
Help me pick a library.
The 2026 landscape:
**Zod (the de facto standard)**:
```typescript
import { z } from 'zod';
const UserSchema = z.object({
email: z.string().email(),
age: z.number().int().min(13).max(120),
role: z.enum(['user', 'admin']),
});
type User = z.infer<typeof UserSchema>;
// Auto-typed: { email: string; age: number; role: 'user' | 'admin' }
const result = UserSchema.safeParse(req.body);
if (!result.success) return new Response(JSON.stringify(result.error.issues), { status: 400 });
const user: User = result.data; // Validated + typed
Pros: largest ecosystem (Hono / tRPC / React Hook Form / OpenAPI / etc. integrate); great DX; chainable API; Zod 3 is mature, Zod 4 (released 2024) is faster. Cons: bundle size ~30KB (gzipped); some performance overhead.
Valibot (the 2024-26 challenger):
import { object, string, email, pipe, number, integer, minValue, maxValue, picklist, parse } from 'valibot';
const UserSchema = object({
email: pipe(string(), email()),
age: pipe(number(), integer(), minValue(13), maxValue(120)),
role: picklist(['user', 'admin']),
});
const user = parse(UserSchema, req.body);
Pros: 90% smaller bundle (~3KB); modular tree-shakable; faster. Cons: smaller ecosystem; functional API style takes adjustment.
TypeBox (JSON Schema interop):
import { Type, Static } from '@sinclair/typebox';
import { Value } from '@sinclair/typebox/value';
const UserSchema = Type.Object({
email: Type.String({ format: 'email' }),
age: Type.Integer({ minimum: 13, maximum: 120 }),
role: Type.Union([Type.Literal('user'), Type.Literal('admin')]),
});
type User = Static<typeof UserSchema>;
const user = Value.Parse(UserSchema, req.body);
Pros: outputs JSON Schema (great for OpenAPI / docs); blazing-fast; small. Cons: less ergonomic than Zod; smaller ecosystem.
ArkType (newer; type-system-rich):
import { type } from 'arktype';
const User = type({
email: 'string.email',
age: 'number.integer & >= 13 & <= 120',
role: "'user' | 'admin'",
});
Pros: very fast; expressive type-string syntax. Cons: smaller ecosystem; less battle-tested.
Yup (legacy): Older. Use only if migrating an existing Yup codebase. New code: Zod / Valibot.
Joi (legacy backend): Pre-TypeScript era. Don't use for new code.
Pick by priority:
| Priority | Pick |
|---|---|
| Default + ecosystem | Zod |
| Bundle size critical | Valibot |
| OpenAPI / JSON Schema | TypeBox |
| Performance critical | TypeBox or ArkType |
| Existing Yup codebase | Migrate to Zod |
For my project:
- Bundle size constraints
- Frontend / backend / both
- Existing libraries
Output:
- Library pick
- Rationale
- Migration plan if applicable
The 2026 default: **Zod**. The ecosystem advantage outweighs alternatives' bundle / perf edge for most teams. Switch to Valibot when bundle size is critical (mobile / extension / edge). Switch to TypeBox when JSON Schema interop is the constraint.
## Defining Schemas Right
Help me write good schemas.
The basic patterns:
Object with required + optional:
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1).max(200),
age: z.number().int().min(13).max(120).optional(), // Optional
role: z.enum(['user', 'admin']).default('user'), // Default value
createdAt: z.string().datetime(), // ISO 8601
});
Strict object (no extra fields allowed):
const UserSchema = z.object({
email: z.string().email(),
}).strict(); // Throws if extra fields present
// Or use .passthrough() to allow + preserve extras
// Default behavior strips extras silently
Discriminated union (events / messages):
const EventSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('signup'), email: z.string().email() }),
z.object({ type: z.literal('purchase'), amount: z.number().positive() }),
z.object({ type: z.literal('logout'), sessionId: z.string() }),
]);
type Event = z.infer<typeof EventSchema>;
// Discriminated; handle each type-narrowed
Refinements (custom validation):
const PasswordSchema = z.string()
.min(8, 'Password must be at least 8 characters')
.refine(p => /[A-Z]/.test(p), 'Must include uppercase')
.refine(p => /[0-9]/.test(p), 'Must include number');
Transforms (parse + reshape):
const TrimmedString = z.string().transform(s => s.trim());
const UserSchema = z.object({
email: z.string().email().transform(s => s.toLowerCase()),
birthday: z.string().transform(s => new Date(s)), // string → Date
});
Async validation (DB checks):
const UniqueEmail = z.string().email().refine(
async email => !(await db.user.findUnique({ where: { email } })),
'Email already taken'
);
await UniqueEmail.parseAsync(input);
Coercion (lenient parsing):
// For query params / form data where everything is string
const SearchSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});
SearchSchema.parse({ page: '3', pageSize: '50' });
// Coerces strings to numbers
Anti-patterns:
z.any()everywhere defeats the purposez.unknown()is fine but YOU must validate further- Schemas without bounds (no min/max on strings = potential DoS via huge inputs)
- Schemas duplicated (frontend / backend / docs out of sync)
For my schemas: [API surface]
Output:
- Schema patterns to apply
- Per-endpoint schemas
- Common refinements / transforms
The discipline: **always set bounds**. `z.string()` with no `.max()` allows 1GB strings — easy DoS. `z.number()` with no `.max()` allows `Number.MAX_SAFE_INTEGER`. Set sensible bounds even for "unbounded" fields.
## Type Inference: One Source of Truth
Help me derive types from schemas.
The pattern: schema is the source of truth; types derive.
// Define schema once
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
age: z.number().int().min(13),
});
// Derive type
type User = z.infer<typeof UserSchema>;
// { id: string; email: string; age: number }
// Use in functions
function createUser(input: User) { /* ... */ }
function updateUser(id: string, input: Partial<User>) { /* ... */ }
Variants:
// Input type (before transforms applied)
type UserInput = z.input<typeof UserSchema>;
// Output type (after transforms applied)
type UserOutput = z.output<typeof UserSchema>;
// (Only differs if you have transforms; .infer === .output)
Composing schemas:
// Base
const UserBase = z.object({
email: z.string().email(),
name: z.string(),
});
// Extend
const UserCreate = UserBase.extend({
password: z.string().min(8),
});
// Pick / omit
const UserPublic = UserBase.pick({ email: true, name: true });
const UserPrivate = UserBase.omit({ email: true });
// Partial / required
const UserUpdate = UserBase.partial(); // All fields optional
const UserCreate = UserBase.required(); // All fields required (default for object)
// Merge
const UserWithMeta = UserBase.merge(z.object({
createdAt: z.string().datetime(),
}));
Sharing across frontend + backend:
Monorepo or shared package:
packages/
shared/
src/
schemas.ts ← single source of schemas
api/
package.json // depends on @yourorg/shared
web/
package.json // depends on @yourorg/shared
Frontend:
import { UserCreate } from '@yourorg/shared';
const result = UserCreate.safeParse(formData);
Backend:
import { UserCreate } from '@yourorg/shared';
const validated = UserCreate.parse(req.body);
Both validate the same way; both get the same type.
For my codebase:
- Monorepo / multi-repo
- Frontend / backend separation
Output:
- Schema-package structure
- Shared types
- Migration plan
The win that compounds: **schema = type = docs = validation, all from one definition**. With `zod-to-openapi` (or TypeBox's native JSON Schema), your schema also generates OpenAPI docs. One change updates everything.
## Error Handling: User-Friendly Messages
Help me handle validation errors.
The default Zod error:
{
"issues": [
{ "code": "too_small", "message": "Number must be greater than or equal to 13", "path": ["age"] },
{ "code": "invalid_string", "message": "Invalid email", "path": ["email"] }
]
}
OK for engineers; bad for users.
The transformation:
function formatErrors(error: z.ZodError) {
return error.issues.reduce<Record<string, string>>((acc, issue) => {
const path = issue.path.join('.');
acc[path] = issue.message;
return acc;
}, {});
}
const result = UserSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ error: 'Validation failed', fields: formatErrors(result.error) },
{ status: 400 }
);
}
Returns:
{
"error": "Validation failed",
"fields": {
"age": "Number must be greater than or equal to 13",
"email": "Invalid email"
}
}
Frontend can map fields.age → ageInput.errorMessage.
Custom messages per field:
const UserSchema = z.object({
email: z.string().email('Please enter a valid email address'),
age: z.number({
required_error: "Age is required",
invalid_type_error: "Age must be a number",
}).int().min(13, 'Must be at least 13 years old'),
});
i18n (multi-language errors):
import { ZodError } from 'zod';
const i18nMessages = {
en: { invalid_email: 'Invalid email' },
es: { invalid_email: 'Email inválido' },
};
function localizedError(error: ZodError, locale: 'en' | 'es') {
return error.issues.map(i => ({
path: i.path.join('.'),
message: i18nMessages[locale][i.code] ?? i.message,
}));
}
For elaborate i18n: zod-i18n-map library.
Different error formats by client:
// REST API: structured errors
return Response.json({ error: 'Validation failed', fields }, { status: 400 });
// Form submission (HTML): re-render with errors
return new Response(htmlWithErrors, { status: 400 });
// React Hook Form integration: zodResolver does the mapping
Logging validation failures:
Validation failures often hint at bugs (frontend sending wrong data) or attacks (probing for inputs).
if (!result.success) {
logger.warn('validation_failed', {
endpoint: req.url,
issues: result.error.issues,
bodyShape: typeof req.body, // Don't log full body (PII)
});
return Response.json({ error: ... }, { status: 400 });
}
For my UI:
- Error display
- i18n needs
Output:
- Error formatter
- Frontend integration
- Logging
The non-obvious detail: **don't log the full request body in validation errors**. Bodies often contain PII / tokens. Log structure (`bodyShape`) and which fields failed (`field paths`), not values.
## Validation in Common Frameworks
Help me wire validation into my framework.
Next.js 16 (App Router):
// app/api/users/route.ts
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
});
export async function POST(req: Request) {
const body = await req.json();
const result = CreateUserSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ error: 'Validation failed', issues: result.error.issues },
{ status: 400 }
);
}
// result.data is fully typed
const user = await db.user.create({ data: result.data });
return Response.json(user, { status: 201 });
}
tRPC:
import { z } from 'zod';
import { protectedProcedure, router } from './trpc';
export const userRouter = router({
create: protectedProcedure
.input(z.object({
email: z.string().email(),
name: z.string().min(1),
}))
.mutation(async ({ input, ctx }) => {
return ctx.db.user.create({ data: input });
}),
});
// tRPC handles validation automatically; input is typed
Hono:
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
app.post('/users',
zValidator('json', z.object({
email: z.string().email(),
name: z.string().min(1),
})),
async (c) => {
const data = c.req.valid('json');
// typed
return c.json(await db.user.create({ data }));
}
);
Express / Fastify:
// Express
app.post('/users', async (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) return res.status(400).json(result.error);
// ...
});
// Fastify (with @fastify/type-provider-zod for tighter integration)
fastify.post('/users', { schema: { body: CreateUserSchema } }, async (req) => {
// req.body is typed
});
React Hook Form:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const Schema = z.object({
email: z.string().email(),
name: z.string().min(1),
});
function MyForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(Schema),
});
return (
<form onSubmit={handleSubmit(submit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
</form>
);
}
For my framework: [stack]
Output:
- Wiring example
- Middleware / helper for repeated validation
- Error response convention
The pattern that scales: **a validation middleware**. Don't repeat `safeParse` + error handling in every route. Write once; apply everywhere.
## Validating Environment Variables
Help me validate env vars.
Env vars are notorious for "works in dev; missing in prod" failures.
The pattern:
// env.ts
import { z } from 'zod';
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production']),
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
RESEND_API_KEY: z.string(),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
FEATURE_FLAG_X: z.coerce.boolean().default(false),
});
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment variables:', parsed.error.format());
process.exit(1); // Fail fast on misconfiguration
}
export const env = parsed.data;
// Now `env.STRIPE_SECRET_KEY` is typed string; missing = boot fails
Use env.X everywhere:
// Good
import { env } from '@/env';
const stripe = new Stripe(env.STRIPE_SECRET_KEY);
// Bad
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); // The ! lies
Library: t3-env (@t3-oss/env-nextjs) is purpose-built; integrates with Next.js.
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string(),
},
client: {
NEXT_PUBLIC_API_URL: z.string().url(),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
},
});
t3-env enforces server vs client split (prevents leaking server-only env to client bundles).
For my project: [framework]
Output:
- Env schema
- Boot-time validation
- Migration plan
The win: **the deploy that's missing an env var fails immediately at startup**, not 30 minutes later when the first user hits a code path that needs it. Boot-time validation surfaces config issues fast.
## Validating External API Responses
Help me validate incoming third-party data.
Trust no API. Even Stripe / Twilio / OpenAI can change response shapes.
The pattern:
const StripeChargeResponseSchema = z.object({
id: z.string(),
amount: z.number().int(),
currency: z.string(),
status: z.enum(['succeeded', 'pending', 'failed']),
created: z.number(), // Unix timestamp
});
async function chargeCard(amount: number) {
const resp = await fetch('https://api.stripe.com/v1/charges', { ... });
const json = await resp.json();
const result = StripeChargeResponseSchema.safeParse(json);
if (!result.success) {
logger.error('stripe_unexpected_response', { json, issues: result.error.issues });
throw new Error('Stripe API returned unexpected response');
}
return result.data;
}
Why: third-party API changes shape, your app crashes deep in code. Validation surfaces "Stripe added a new status enum value" within minutes — fix before customer impact.
Webhook payloads:
Even more critical — webhooks come from outside your control.
const StripeWebhookSchema = z.object({
id: z.string(),
type: z.string(),
data: z.object({
object: z.unknown(), // Per-event-type narrowing later
}),
});
Then per event type:
const ChargeSucceededSchema = z.object({
type: z.literal('charge.succeeded'),
data: z.object({
object: z.object({
id: z.string(),
amount: z.number(),
}),
}),
});
// Discriminated union of all events
const StripeEvent = z.discriminatedUnion('type', [
ChargeSucceededSchema,
// ... others
]);
For my third-party integrations:
- Stripe / Resend / OpenAI / etc. responses
- Webhook payloads
Output:
- Per-API schemas
- Logging on shape mismatch
- Migration when API changes
The non-obvious benefit: **detecting API changes before customers do**. "Stripe added a new event type"; your webhook validation fires; you log + skip; you fix the code in next sprint instead of being woken up at 3 AM.
## Common Validation Pitfalls
Help me avoid pitfalls.
The 10 mistakes:
1. Trusting TypeScript at runtime TS doesn't validate runtime data. Always validate at boundaries.
2. z.any() everywhere Defeats the purpose. Use specific types or z.unknown() + further validation.
3. No bounds (min/max) Allows 1GB strings or MAX_SAFE_INTEGER numbers; potential DoS.
4. Validation drift between frontend and backend Schemas duplicated, drift over time. Share schemas via package.
5. Stack-trace in error response Leaks internal structure. Format errors for users.
6. Logging full request body on failure PII / tokens leaked to logs.
7. No env var validation Boots fine; crashes when first request needs missing key.
8. Async refinements without error handling Failed DB query in refinement = unhelpful error.
9. Validating at route handler but not in service layer Defense in depth: validate at entry; types-flow is enough internally.
10. Skipping webhook payload validation Third-party webhook shapes change; bugs accumulate silently.
For my code: [risks]
Output:
- Top 3 risks
- Mitigations
- Coverage plan
The mistake that hides longest: **validation drift across frontend and backend**. Frontend says email is required + max 100 chars. Backend has no validation. User pastes 10K-character "email"; DB stores; renders; layout breaks. Share schemas.
## What Done Looks Like
A working validation strategy delivers:
- Every API endpoint has a Zod schema for body / query / path
- Every webhook payload validated before processing
- Every external API response validated
- Environment variables validated at boot (fail fast)
- Schemas shared between frontend + backend (single source)
- Types inferred from schemas (one source of truth)
- User-friendly error responses with field-level detail
- Validation failures logged (without PII) for monitoring
- Tests for happy path + edge cases + adversarial inputs
- Bundle size impact monitored (Zod ~30KB; switch to Valibot if it matters)
The proof you got it right: a malformed API request returns 400 with a field-level error message; the same request 6 months ago would have crashed in business logic. Webhooks from a renamed-field upgrade keep working (validated against schema; fails fast; alert raised).
## See Also
- [API Versioning](api-versioning-chat.md) — schema versioning
- [API Pagination Patterns](api-pagination-patterns-chat.md) — query-param validation
- [Inbound Webhooks](inbound-webhooks-chat.md) — webhook payload validation
- [Public API](public-api-chat.md) — API design + validation
- [API Documentation Tools](https://vibereference.dev/backend-and-data/api-documentation-tools) — schemas → OpenAPI docs
- [Logging Strategy & Structured Logs](logging-strategy-structured-logs-chat.md) — log validation failures
- [Idempotency Patterns](idempotency-patterns-chat.md) — companion API discipline
- [HTTP Retry & Backoff](http-retry-backoff-chat.md) — companion API discipline
- [CSV Import](csv-import-chat.md) — row validation for imports
- [VibeReference: TypeScript Patterns](https://vibereference.dev/frontend/typescript-patterns) — type-system context
- [VibeReference: Zod](https://vibereference.dev/frontend/zod) — Zod reference