import { OpenAPIRegistry, extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
import { z } from 'zod';
extendZodWithOpenApi(z);
export const registry = new OpenAPIRegistry();
export interface RouteConfig {
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
path: string;
summary: string;
body?: z.ZodTypeAny;
params?: z.AnyZodObject;
query?: z.AnyZodObject;
responses: Record<number, { description: string; schema?: z.ZodTypeAny }>;
}
export function defineRoute(config: RouteConfig): RouteConfig {
registry.registerPath({
method: config.method,
path: config.path,
summary: config.summary,
request: {
params: config.params,
query: config.query,
body: config.body
? { content: { 'application/json': { schema: config.body } } }
: undefined,
},
responses: Object.fromEntries(
Object.entries(config.responses).map(([status, r]) => [
status,
{
description: r.description,
content: r.schema
? { 'application/json': { schema: r.schema } }
: undefined,
},
]),
),
});
return config;
}
import { Request, Response, NextFunction, RequestHandler } from 'express';
import { ZodError } from 'zod';
import { RouteConfig } from './registry';
function formatIssues(err: ZodError) {
return err.issues.map((i) => ({
field: i.path.join('.') || '(root)',
message: i.message,
}));
}
export function validateRequest(config: RouteConfig): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
const targets: Array<[keyof Request, RouteConfig[keyof RouteConfig]]> = [
['body', config.body],
['query', config.query],
['params', config.params],
];
for (const [key, schema] of targets) {
if (!schema) continue;
const result = (schema as any).safeParse(req[key as 'body']);
if (!result.success) {
return res.status(400).json({
error: 'ValidationError',
in: key,
issues: formatIssues(result.error),
});
}
(req as any)[key] = result.data;
}
next();
};
}
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import { defineRoute } from './registry';
import { validateRequest } from './validate';
export const router = Router();
const CreateUserBody = z
.object({
email: z.string().email().openapi({ example: 'ada@example.com' }),
name: z.string().min(1).openapi({ example: 'Ada Lovelace' }),
role: z.enum(['admin', 'member']).default('member'),
})
.openapi('CreateUserBody');
const UserResponse = z
.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
role: z.enum(['admin', 'member']),
createdAt: z.string().datetime(),
})
.openapi('UserResponse');
const ErrorResponse = z
.object({ error: z.string(), issues: z.array(z.any()).optional() })
.openapi('ErrorResponse');
const createUser = defineRoute({
method: 'post',
path: '/users',
summary: 'Create a new user',
body: CreateUserBody,
responses: {
201: { description: 'Created', schema: UserResponse },
400: { description: 'Invalid payload', schema: ErrorResponse },
},
});
router.post('/users', validateRequest(createUser), (req: Request, res: Response) => {
const input = req.body as z.infer<typeof CreateUserBody>;
const user = {
id: crypto.randomUUID(),
...input,
createdAt: new Date().toISOString(),
};
res.status(201).json(user);
});
import { writeFileSync } from 'node:fs';
import { OpenApiGeneratorV31 } from '@asteasolutions/zod-to-openapi';
import { registry } from './registry';
// Side-effect import: registers every path onto the shared registry.
import './users.routes';
function buildSpec() {
const generator = new OpenApiGeneratorV31(registry.definitions);
return generator.generateDocument({
openapi: '3.1.0',
info: {
title: 'Users API',
version: '1.0.0',
description: 'Generated from Zod schemas — do not edit by hand.',
},
servers: [{ url: 'https://api.example.com/v1' }],
});
}
const spec = buildSpec();
writeFileSync('openapi.json', JSON.stringify(spec, null, 2));
console.log(`Wrote openapi.json with ${Object.keys(spec.paths ?? {}).length} paths`);
This snippet shows how a single Zod schema per route can drive both runtime request validation and a generated OpenAPI 3.1 document, eliminating the drift that plagues hand-written swagger files. The core idea is that the schema is the source of truth: rather than describing an endpoint twice (once for validation, once for docs), each route registers its schemas in one place and both artifacts are derived from that registration.
In registry.ts, @asteasolutions/zod-to-openapi extends Zod with an .openapi() method and provides an OpenAPIRegistry. The defineRoute helper wraps registry.registerPath, so calling it records the method, path, request body, params, and response schemas in a central registry. Crucially it returns the same config object back, which lets the caller reuse those exact schemas for validation — the registration and the validation contract can never diverge because they are literally the same objects.
validate.ts turns a route config into Express middleware. validateRequest parses req.body, req.query, and req.params against the registered schemas using safeParse, and on failure responds with a 400 whose body matches the shape declared for error responses. Using safeParse instead of parse avoids throwing inside the request path and keeps error handling explicit. The parsed, coerced values are written back onto the request so downstream handlers receive typed, validated data rather than raw strings.
users.routes.ts ties it together: it defines CreateUserBody and UserResponse schemas with .openapi() metadata, registers the POST /users route via defineRoute, and mounts the handler behind validateRequest. Because the schema carries example values and descriptions, the generated spec is rich without extra annotation.
generate-spec.ts walks the populated registry with OpenApiGeneratorV31 and writes openapi.json, a step suitable for a build or CI job so the committed spec always reflects the code. The main trade-off is coupling the API to Zod and a specific generator, but in exchange documentation, validation, and TypeScript types stay in lockstep. A common pitfall is forgetting to import route modules before generation — the registry is only populated as a side effect of those imports, so the generator must load every route file first.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
import { randomBytes, createHash } from "crypto";
import jwt from "jsonwebtoken";
import { RefreshTokenStore } from "./store";
const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const ACCESS_TTL = "15m";
JWT access + refresh token rotation (conceptual)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
Share this code
Here's the card — post it anywhere.