["API endpoint input validation requirements","Form validation schema design","Runtime type checking needs","Shared validation between client and server"]
outputs
["Zod schema definitions with transforms","API input validation middleware","Shared client/server validation patterns","Custom validation rules and error messages"]
linksTo
["zod","forms","api-designer"]
linkedFrom
["forms","api-designer"]
preferredNextSkills
["zod","forms"]
fallbackSkills
["api-designer"]
riskLevel
low
memoryReadPolicy
selective
memoryWritePolicy
none
sideEffects
[]
Data Validation Patterns
Purpose
Implement robust data validation at every boundary — API inputs, form submissions, environment variables, external data, and database writes. Covers Zod schema design, shared client/server validation, custom validators, and validation error handling.
Key Patterns
Zod Schema Design
Basic schemas with transforms:
// schemas/user.tsimport { z } from'zod';
exportconst createUserSchema = z.object({
name: z
.string()
.min(2, 'Name must be at least 2 characters')
.max(100, 'Name must be under 100 characters')
.trim(),
email: z
.string()
.email('Invalid email address')
.toLowerCase()
.trim(),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain an uppercase letter')
.regex(/[0-9]/, 'Must contain a number')
.regex(/[^A-Za-z0-9]/, 'Must contain a special character'),
age: z
.number()
.int('Age must be a whole number')
.(, )
.(, )
.(),
: z.([, , ]).(),
});
= z.< createUserSchema>;
updateUserSchema = createUserSchema
.({ : })
.();
= z.< updateUserSchema>;
min
13
'Must be at least 13 years old'
max
150
'Invalid age'
optional
role
enum
'user'
'admin'
'moderator'
default
'user'
// Infer TypeScript types from schemas
export
type
CreateUserInput
infer
typeof
// Update schema — make all fields optional, omit password
export
const
omit
password
true
partial
export
type
UpdateUserInput
infer
typeof
Complex schemas with refinements:
// schemas/order.tsconst lineItemSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
unitPrice: z.number().positive(),
});
const addressSchema = z.object({
street: z.string().min(1),
city: z.string().min(1),
state: z.string().length(2),
zip: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code'),
country: z.string().length(2).default('US'),
});
exportconst createOrderSchema = z
.object({
items: z.array(lineItemSchema).min(1, 'Order must have at least one item'),
shippingAddress: addressSchema,
billingAddress: addressSchema.optional(),
couponCode: z
.string()
.regex(/^[A-Z0-9]{4,12}$/, 'Invalid coupon format')
.optional(),
useSameAddress: z.boolean().default(true),
})
.refine(
(data) => data.useSameAddress || data.billingAddress !== undefined,
{
message: 'Billing address required when not using shipping address',
path: ['billingAddress'],
}
)
.transform((data) => ({
...data,
billingAddress: data.useSameAddress ? data.shippingAddress : data.billingAddress!,
}));
// app/api/contact/route.tsimport { contactFormSchema } from'@/schemas/contact-form';
import { withValidation } from'@/lib/validate';
exportconstPOST = withValidation(contactFormSchema, async (request, data) => {
// data is already validated with the same schema used on the clientawaitsendEmail(data);
returnNextResponse.json({ success: true });
});
Custom Validators
// schemas/validators.tsimport { z } from'zod';
// Slug validatorexportconst slug = z
.string()
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Invalid slug format')
.min(1)
.max(200);
// Phone number (E.164)exportconst phoneNumber = z
.string()
.regex(/^\+[1-9]\d{1,14}$/, 'Invalid phone number (use E.164 format: +1234567890)');
// URL that must be HTTPS in productionexportconst secureUrl = z
.string()
.url()
.refine(
(url) => process.env.NODE_ENV !== 'production' || url.startsWith('https://'),
'HTTPS required in production'
);
// Date string that parses to valid Dateexportconst dateString = z
.string()
.datetime()
.transform((s) =>newDate(s));
// Currency amount in cents (avoid floating point)exportconst currencyAmount = z
.number()
.int('Amount must be in cents (integer)')
.nonnegative('Amount cannot be negative');
// JSON string that parses to objectexportconst jsonString = z
.string()
.transform((s, ctx) => {
try {
returnJSON.parse(s);
} catch {
ctx.addIssue({ code: 'custom', message: 'Invalid JSON string' });
return z.NEVER;
}
});
Validation Error Formatting
// lib/format-errors.tsimport { ZodError } from'zod';
// Flat field-level errors for formsexportfunctionflattenErrors(error: ZodError): Record<string, string> {
constflat: Record<string, string> = {};
for (const issue of error.errors) {
const path = issue.path.join('.');
if (!flat[path]) {
flat[path] = issue.message; // First error per field
}
}
return flat;
}
// Structured errors for API responsesexportfunctionformatApiErrors(error: ZodError) {
return error.errors.map((e) => ({
field: e.path.join('.'),
message: e.message,
code: e.code,
...(e.code === 'invalid_enum_value' && {
expected: (e asany).options,
received: (e asany).received,
}),
}));
}
Best Practices
Validate at every boundary — API inputs, form submissions, environment variables, webhook payloads, and external API responses. Never trust incoming data.
Single source of truth — Define Zod schemas once, share between client and server. Infer TypeScript types from schemas with z.infer.
Use safeParse, not parse — safeParse returns a result object; parse throws. Prefer safeParse in API handlers for controlled error responses.
Transform during validation — Use .trim(), .toLowerCase(), .transform() to normalize data as part of validation.
Validate environment at startup — Crash immediately if required env vars are missing. Do not discover missing config at runtime.
Coerce query parameters — URL params are always strings. Use z.coerce.number() for numeric query params.
Prefer specific error messages — "Password must be at least 8 characters" beats "Invalid input".
Never validate only on the client — Client-side validation is for UX (fast feedback). Server-side validation is for security. Always do both.
Common Pitfalls
Pitfall
Problem
Fix
Client-only validation
Malicious users bypass client checks
Always validate server-side, even if client validates too
Exposing Zod errors directly
Raw error structure confuses API consumers
Format errors into consistent API error shape
Using parse in API handlers
Unhandled ZodError crashes the endpoint
Use safeParse and return 422 with formatted errors
Missing trim() on strings
" admin@test.com " passes email validation but fails lookups
Always .trim() string inputs
Floating-point currency
0.1 + 0.2 !== 0.3 causes billing bugs
Use integer cents: z.number().int() for amounts
No max length on strings
Unbounded input allows DoS via massive payloads
Set .max() on all string fields
Duplicating schemas
Client and server schemas diverge over time
Define once in schemas/, import everywhere
Not validating arrays
Array of 1M items accepted
Add .max() to array schemas and .max() to string items within