DEV Community

Young Gao
Young Gao

Posted on

Building a Type-Safe REST API with Zod, Express, and TypeScript: From Validation to OpenAPI Docs

Every REST API needs input validation and documentation. Most teams treat these as separate concerns — writing Joi schemas for validation, then manually maintaining OpenAPI specs. They inevitably drift apart. Here'''s how to use Zod as the single source of truth for both.

What We'''re Building

A complete REST API for a task management system with:

  • Request/response validation using Zod schemas
  • Automatic OpenAPI 3.1 spec generation from those schemas
  • Type-safe request handlers (no any types)
  • Proper error responses with structured validation messages

Prerequisites

  • Node.js 20+ and npm
  • Basic TypeScript and Express knowledge
  • Familiarity with REST API concepts

Project Setup

mkdir task-api && cd task-api
npm init -y
npm install express zod zod-to-openapi uuid
npm install -D typescript @types/express @types/uuid tsx
Enter fullscreen mode Exit fullscreen mode

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src/**/*"]
}
Enter fullscreen mode Exit fullscreen mode

Step 1: Define Schemas as the Source of Truth

// src/schemas/task.ts
import { z } from '''zod''';
import { extendZodWithOpenApi } from '''zod-to-openapi''';

extendZodWithOpenApi(z);

// Base task schema — represents what'''s stored
export const TaskSchema = z.object({
  id: z.string().uuid().openapi({ example: '''550e8400-e29b-41d4-a716-446655440000''' }),
  title: z.string().min(1).max(200).openapi({ example: '''Ship v2.0 release''' }),
  description: z.string().max(2000).optional().openapi({ example: '''Final QA pass and deploy''' }),
  status: z.enum(['''todo''', '''in_progress''', '''done''']).openapi({ example: '''todo''' }),
  priority: z.enum(['''low''', '''medium''', '''high''', '''critical''']).openapi({ example: '''high''' }),
  assignee: z.string().email().optional().openapi({ example: '''dev@company.com''' }),
  dueDate: z.string().datetime().optional().openapi({ example: '''2025-06-15T00:00:00Z''' }),
  tags: z.array(z.string().max(50)).max(10).default([]).openapi({ example: ['''backend''', '''release'''] }),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
}).openapi('''Task''');

// Create request — subset of fields
export const CreateTaskSchema = TaskSchema.pick({
  title: true,
  description: true,
  priority: true,
  assignee: true,
  dueDate: true,
  tags: true,
}).extend({
  title: z.string().min(1).max(200),
}).openapi('''CreateTask''');

// Update request — all fields optional
export const UpdateTaskSchema = CreateTaskSchema.partial().openapi('''UpdateTask''');

// Query parameters for listing
export const TaskQuerySchema = z.object({
  status: z.enum(['''todo''', '''in_progress''', '''done''']).optional(),
  priority: z.enum(['''low''', '''medium''', '''high''', '''critical''']).optional(),
  assignee: z.string().email().optional(),
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  sortBy: z.enum(['''createdAt''', '''updatedAt''', '''dueDate''', '''priority''']).default('''createdAt'''),
  order: z.enum(['''asc''', '''desc''']).default('''desc'''),
}).openapi('''TaskQuery''');

// Derive TypeScript types directly from schemas
export type Task = z.infer<typeof TaskSchema>;
export type CreateTask = z.infer<typeof CreateTaskSchema>;
export type UpdateTask = z.infer<typeof UpdateTaskSchema>;
export type TaskQuery = z.infer<typeof TaskQuerySchema>;
Enter fullscreen mode Exit fullscreen mode

Every type, every validation rule, every OpenAPI example lives in one place. Change the schema, and validation, types, and docs all update together.

Step 2: Type-Safe Validation Middleware

// src/middleware/validate.ts
import { Request, Response, NextFunction } from '''express''';
import { z, ZodError, ZodSchema } from '''zod''';

type ValidationTarget = '''body''' | '''query''' | '''params''';

interface ValidationSchema {
  body?: ZodSchema;
  query?: ZodSchema;
  params?: ZodSchema;
}

function formatZodError(error: ZodError) {
  return {
    error: '''Validation failed''',
    details: error.issues.map((issue) => ({
      path: issue.path.join('''.'''),
      message: issue.message,
      code: issue.code,
    })),
  };
}

export function validate(schemas: ValidationSchema) {
  return (req: Request, res: Response, next: NextFunction) => {
    const targets: ValidationTarget[] = ['''body''', '''query''', '''params'''];
    const errors: z.ZodIssue[] = [];

    for (const target of targets) {
      const schema = schemas[target];
      if (!schema) continue;

      const result = schema.safeParse(req[target]);
      if (!result.success) {
        for (const issue of result.error.issues) {
          issue.path = [target, ...issue.path];
          errors.push(issue);
        }
      } else {
        (req as any)[target] = result.data;
      }
    }

    if (errors.length > 0) {
      const zodError = new ZodError(errors);
      return res.status(400).json(formatZodError(zodError));
    }

    next();
  };
}
Enter fullscreen mode Exit fullscreen mode

Two details matter here. First, safeParse returns the transformed data (coerced numbers, defaults applied), and we replace req.body/req.query with it. Downstream handlers get clean, typed data. Second, we validate all targets before responding, so the client sees every error at once.

Step 3: Request Handlers with Full Type Safety

// src/routes/tasks.ts
import { Router, Request, Response } from '''express''';
import { v4 as uuidv4 } from '''uuid''';
import { validate } from '''../middleware/validate.js''';
import {
  CreateTaskSchema,
  UpdateTaskSchema,
  TaskQuerySchema,
  type Task,
  type CreateTask,
  type TaskQuery,
} from '''../schemas/task.js''';
import { z } from '''zod''';

const router = Router();
const tasks = new Map<string, Task>();

const IdParamSchema = z.object({ id: z.string().uuid() });

// LIST tasks with filtering, pagination, sorting
router.get('''/''',
  validate({ query: TaskQuerySchema }),
  (req: Request, res: Response) => {
    const query = req.query as unknown as TaskQuery;
    let results = Array.from(tasks.values());

    if (query.status) results = results.filter((t) => t.status === query.status);
    if (query.priority) results = results.filter((t) => t.priority === query.priority);
    if (query.assignee) results = results.filter((t) => t.assignee === query.assignee);

    results.sort((a, b) => {
      const aVal = a[query.sortBy] ?? '''''';
      const bVal = b[query.sortBy] ?? '''''';
      const cmp = String(aVal).localeCompare(String(bVal));
      return query.order === '''asc''' ? cmp : -cmp;
    });

    const total = results.length;
    const start = (query.page - 1) * query.limit;
    const items = results.slice(start, start + query.limit);

    res.json({
      items,
      pagination: { page: query.page, limit: query.limit, total, totalPages: Math.ceil(total / query.limit) },
    });
  }
);

// CREATE task
router.post('''/''',
  validate({ body: CreateTaskSchema }),
  (req: Request, res: Response) => {
    const input = req.body as CreateTask;
    const now = new Date().toISOString();
    const task: Task = {
      id: uuidv4(),
      status: '''todo''',
      ...input,
      tags: input.tags ?? [],
      createdAt: now,
      updatedAt: now,
    };
    tasks.set(task.id, task);
    res.status(201).json(task);
  }
);

// GET single task
router.get('''/:id''',
  validate({ params: IdParamSchema }),
  (req: Request, res: Response) => {
    const task = tasks.get(req.params.id);
    if (!task) return res.status(404).json({ error: '''Task not found''' });
    res.json(task);
  }
);

// UPDATE task
router.patch('''/:id''',
  validate({ params: IdParamSchema, body: UpdateTaskSchema }),
  (req: Request, res: Response) => {
    const existing = tasks.get(req.params.id);
    if (!existing) return res.status(404).json({ error: '''Task not found''' });
    const updated: Task = {
      ...existing,
      ...req.body,
      id: existing.id,
      createdAt: existing.createdAt,
      updatedAt: new Date().toISOString(),
    };
    tasks.set(updated.id, updated);
    res.json(updated);
  }
);

// DELETE task
router.delete('''/:id''',
  validate({ params: IdParamSchema }),
  (req: Request, res: Response) => {
    if (!tasks.delete(req.params.id)) return res.status(404).json({ error: '''Task not found''' });
    res.status(204).send();
  }
);

export default router;
Enter fullscreen mode Exit fullscreen mode

Notice there are zero manual type assertions beyond the initial cast from req.query. The validation middleware guarantees the shape, and TypeScript carries it forward.

Step 4: Auto-Generate OpenAPI Spec

// src/openapi.ts
import { OpenAPIRegistry, OpenApiGeneratorV31 } from '''zod-to-openapi''';
import { TaskSchema, CreateTaskSchema, UpdateTaskSchema, TaskQuerySchema } from '''./schemas/task.js''';
import { z } from '''zod''';

const registry = new OpenAPIRegistry();

registry.register('''Task''', TaskSchema);
registry.register('''CreateTask''', CreateTaskSchema);
registry.register('''UpdateTask''', UpdateTaskSchema);

const IdParam = registry.registerParameter('''taskId''',
  z.string().uuid().openapi({ param: { name: '''id''', in: '''path''' } })
);

registry.registerPath({
  method: '''get''',
  path: '''/api/tasks''',
  summary: '''List tasks with filtering and pagination''',
  request: { query: TaskQuerySchema },
  responses: {
    200: {
      description: '''Paginated task list''',
      content: {
        '''application/json''': {
          schema: z.object({
            items: z.array(TaskSchema),
            pagination: z.object({
              page: z.number(), limit: z.number(),
              total: z.number(), totalPages: z.number(),
            }),
          }),
        },
      },
    },
  },
});

registry.registerPath({
  method: '''post''',
  path: '''/api/tasks''',
  summary: '''Create a new task''',
  request: { body: { content: { '''application/json''': { schema: CreateTaskSchema } } } },
  responses: {
    201: { description: '''Task created''', content: { '''application/json''': { schema: TaskSchema } } },
    400: { description: '''Validation error''' },
  },
});

registry.registerPath({
  method: '''get''',
  path: '''/api/tasks/{id}''',
  summary: '''Get a task by ID''',
  request: { params: z.object({ id: IdParam }) },
  responses: {
    200: { description: '''Task found''', content: { '''application/json''': { schema: TaskSchema } } },
    404: { description: '''Task not found''' },
  },
});

registry.registerPath({
  method: '''patch''',
  path: '''/api/tasks/{id}''',
  summary: '''Update a task''',
  request: {
    params: z.object({ id: IdParam }),
    body: { content: { '''application/json''': { schema: UpdateTaskSchema } } },
  },
  responses: {
    200: { description: '''Task updated''', content: { '''application/json''': { schema: TaskSchema } } },
    404: { description: '''Task not found''' },
  },
});

registry.registerPath({
  method: '''delete''',
  path: '''/api/tasks/{id}''',
  summary: '''Delete a task''',
  request: { params: z.object({ id: IdParam }) },
  responses: { 204: { description: '''Task deleted''' }, 404: { description: '''Task not found''' } },
});

const generator = new OpenApiGeneratorV31(registry.definitions);
export const openApiSpec = generator.generateDocument({
  openapi: '''3.1.0''',
  info: { title: '''Task Management API''', version: '''1.0.0''', description: '''Type-safe REST API with automatic validation and documentation''' },
  servers: [{ url: '''http://localhost:3000''' }],
});
Enter fullscreen mode Exit fullscreen mode

Step 5: Wire Everything Together

// src/index.ts
import express from '''express''';
import taskRoutes from '''./routes/tasks.js''';
import { openApiSpec } from '''./openapi.js''';

const app = express();
app.use(express.json());
app.get('''/openapi.json''', (_, res) => res.json(openApiSpec));
app.use('''/api/tasks''', taskRoutes);

app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
  console.error(err);
  res.status(500).json({ error: '''Internal server error''' });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
  console.log(`OpenAPI spec: http://localhost:${PORT}/openapi.json`);
});
Enter fullscreen mode Exit fullscreen mode

Testing It

npx tsx src/index.ts
Enter fullscreen mode Exit fullscreen mode

Create a task:

curl -X POST http://localhost:3000/api/tasks \
  -H "Content-Type: application/json" \
  -d '''{"title": "Write API docs", "priority": "high", "tags": ["docs"]}'''
Enter fullscreen mode Exit fullscreen mode

Hit the validation:

curl -X POST http://localhost:3000/api/tasks \
  -H "Content-Type: application/json" \
  -d '''{"title": "", "priority": "extreme"}'''
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "error": "Validation failed",
  "details": [
    { "path": "body.title", "message": "String must contain at least 1 character(s)", "code": "too_small" },
    { "path": "body.priority", "message": "Invalid enum value", "code": "invalid_enum_value" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Why This Works Better Than Alternatives

vs. Joi + Swagger JSDoc: Joi schemas can'''t generate TypeScript types. You maintain types, Joi schemas, and JSDoc annotations separately. Three sources of truth that drift apart.

vs. class-validator + class-transformer: Decorator-based approaches require classes, which conflicts with functional TypeScript. Zod works with plain objects.

vs. tRPC: tRPC is excellent for TypeScript-to-TypeScript communication. But if non-TypeScript clients need your API, you need OpenAPI — and zod-to-openapi gives you that.

Going Further

Add Swagger UI:

npm install swagger-ui-express @types/swagger-ui-express
Enter fullscreen mode Exit fullscreen mode
import swaggerUi from '''swagger-ui-express''';
app.use('''/docs''', swaggerUi.serve, swaggerUi.setup(openApiSpec));
Enter fullscreen mode Exit fullscreen mode

Now http://localhost:3000/docs serves interactive API documentation generated entirely from your Zod schemas.

Conclusion

Zod as the single source of truth eliminates an entire class of bugs — the ones where validation accepts something the type system doesn'''t expect, or the API docs describe a field that the code ignores. Define once, validate everywhere, document automatically.

The full source is about 250 lines of actual logic. The OpenAPI spec it generates is production-ready — import it into Postman, generate client SDKs, or feed it to API gateways.


If this was helpful, you can support my work at ko-fi.com/nopkt


If this article helped you, consider buying me a coffee on Ko-fi! Follow me for more production backend patterns.

Top comments (0)