Your TypeScript types vanish at runtime. Every req.body is any wearing a costume.
The Gap Between Types and Reality
TypeScript gives you compile-time safety. But HTTP requests don't come from your compiler — they come from the internet. A POST /users endpoint typed as { name: string; email: string } will happily accept { name: 42, email: null } at runtime unless you validate.
Most teams handle this one of three ways:
- Manual
ifchecks scattered through handlers (tedious, incomplete) - Class-validator decorators (heavy, reflection-based)
- Nothing (bold strategy)
There's a better path: define your schema once, validate at the edge, generate your OpenAPI spec, and share types across your stack.
Zod: Schema as the Source of Truth
Zod lets you define schemas that are both runtime validators and TypeScript type generators.
import { z } from 'zod';
export const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['admin', 'member', 'viewer']).default('member'),
metadata: z.record(z.string()).optional(),
});
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
// { name: string; email: string; role: 'admin' | 'member' | 'viewer'; metadata?: Record<string, string> }
One definition. Runtime validation and static types. No drift.
The real power shows up with composition:
const PaginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
const SortSchema = z.object({
sortBy: z.string().optional(),
order: z.enum(['asc', 'desc']).default('desc'),
});
const ListUsersQuerySchema = PaginationSchema.merge(SortSchema).extend({
search: z.string().optional(),
role: z.enum(['admin', 'member', 'viewer']).optional(),
});
Query string values are always strings. z.coerce.number() handles the parse-and-validate in one step — no more parseInt sprinkled through your handlers.
Middleware: Validate Before Your Handler Runs
Validation belongs at the edge of your request lifecycle. Your handler should never see invalid data.
import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';
interface ValidationSchemas {
body?: AnyZodObject;
query?: AnyZodObject;
params?: AnyZodObject;
}
function validate(schemas: ValidationSchemas) {
return async (req: Request, res: Response, next: NextFunction) => {
const errors: { location: string; issues: z.ZodIssue[] }[] = [];
for (const [location, schema] of Object.entries(schemas)) {
if (!schema) continue;
const result = await schema.safeParseAsync(req[location as keyof Request]);
if (!result.success) {
errors.push({ location, issues: result.error.issues });
} else {
// Replace raw input with parsed + coerced data
(req as any)[location] = result.data;
}
}
if (errors.length > 0) {
return res.status(422).json(formatValidationError(errors));
}
next();
};
}
Key detail: we replace req.body / req.query with the parsed output. This means defaults are applied, coercions happen, and .optional() fields missing from input become undefined — not the string "undefined".
Usage is clean:
router.post(
'/users',
validate({ body: CreateUserSchema }),
async (req, res) => {
// req.body is validated and typed
const user = await userService.create(req.body);
res.status(201).json(user);
}
);
router.get(
'/users',
validate({ query: ListUsersQuerySchema }),
async (req, res) => {
// req.query.page is already a number, not a string
const result = await userService.list(req.query);
res.json(result);
}
);
Error Response Formatting
Don't leak Zod internals to your API consumers. Normalize errors into a consistent shape.
interface ValidationErrorResponse {
error: 'VALIDATION_ERROR';
message: string;
details: {
location: string;
path: string;
message: string;
}[];
}
function formatValidationError(
errors: { location: string; issues: z.ZodIssue[] }[]
): ValidationErrorResponse {
const details = errors.flatMap(({ location, issues }) =>
issues.map((issue) => ({
location,
path: issue.path.join('.'),
message: issue.message,
}))
);
return {
error: 'VALIDATION_ERROR',
message: `Invalid ${details.map((d) => d.location).join(', ')}`,
details,
};
}
A request with { name: "", email: "not-an-email" } returns:
{
"error": "VALIDATION_ERROR",
"message": "Invalid body",
"details": [
{ "location": "body", "path": "name", "message": "String must contain at least 1 character(s)" },
{ "location": "body", "path": "email", "message": "Invalid email" }
]
}
Consistent. Parseable. No stack traces.
Generating OpenAPI from Zod
Your schemas already describe your API contract. Generate the spec — don't write it by hand.
import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
extendZodWithOpenApi(z);
const registry = new OpenAPIRegistry();
registry.registerPath({
method: 'post',
path: '/users',
request: {
body: {
content: { 'application/json': { schema: CreateUserSchema } },
},
},
responses: {
201: {
description: 'User created',
content: { 'application/json': { schema: UserResponseSchema } },
},
422: {
description: 'Validation error',
content: { 'application/json': { schema: ValidationErrorSchema } },
},
},
});
const generator = new OpenApiGeneratorV3(registry.definitions);
const spec = generator.generateDocument({
openapi: '3.0.3',
info: { title: 'Users API', version: '1.0.0' },
});
// Write spec to file for CI, Swagger UI, or client generation
fs.writeFileSync('openapi.json', JSON.stringify(spec, null, 2));
Now your docs, your validation, and your types all come from the same source. Change the schema, everything updates.
Shared Schemas: Frontend and Backend
This is where the approach pays compound interest. Extract schemas into a shared package.
packages/
shared/
src/
schemas/
user.ts # Zod schemas + inferred types
pagination.ts
index.ts
api/
src/
routes/user.ts # imports from @myapp/shared
web/
src/
api/user.ts # imports from @myapp/shared
Frontend uses the same schemas for form validation:
// packages/web/src/components/CreateUserForm.tsx
import { CreateUserSchema, type CreateUserInput } from '@myapp/shared';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
function CreateUserForm() {
const { register, handleSubmit, formState: { errors } } = useForm<CreateUserInput>({
resolver: zodResolver(CreateUserSchema),
});
const onSubmit = async (data: CreateUserInput) => {
// data is already validated — same rules as the backend
await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* form fields */}
</form>
);
}
Same schema validates the form client-side and the request server-side. A field constraint change propagates to both instantly.
Common Mistakes
Validating inside the handler. If validation is mixed with business logic, you'll forget it on one route. Middleware makes it structural.
Not replacing req.body with parsed output. If you validate but still read the raw input, you miss defaults and coercions. Always assign the parsed result back.
Over-constraining early. Start with loose schemas and tighten. Adding .max(100) is a non-breaking change. Reducing .max(50) to .max(30) is breaking.
Skipping response validation in development. Add a dev-only middleware that validates outgoing responses against your schemas. Catches drift between your actual return values and your documented contract before your consumers do.
Using z.object() for query params without coercion. Query strings are always strings. z.coerce.number() and z.coerce.boolean() exist for this reason. Without them, ?page=2 fails validation because "2" is not a number.
Not versioning your shared package. Monorepo or not, pin versions. A broken schema change in shared shouldn't silently break both your frontend and backend in the same deploy.
Part of my Production Backend Patterns series. Follow for more practical backend engineering.
If this was useful, consider:
- Sponsoring on GitHub to support more open-source tools
- Buying me a coffee on Ko-fi
Top comments (0)