---
name: zod-contract-first-api
description: Design APIs contract-first with Zod schemas as the single source of truth — request validation, response shape, OpenAPI generation, client codegen all derive from one place. Use when starting a new API, refactoring an inconsistent one, or wiring AI tool-use schemas. Works with Hono, Express, Fastify, tRPC.
category: backend
version: 0.1.0
tags: [zod, validation, api, contract, openapi, typescript]
recommended_npm: ["zod", "@hono/zod-validator", "zod-openapi", "zod-to-openapi"]
license: MIT
author: claude-code-skills
---

The contract is one Zod schema per endpoint, defined once. Every consumer — server validator, OpenAPI doc, generated client, AI tool-use spec — comes from that schema.

## Pattern: schema folder, not endpoint files

```
src/contracts/
  product/
    create.ts        # request + response + errors
    list.ts
    get.ts
    update.ts
  user/
    register.ts
  index.ts           # re-exports
```

```ts
// src/contracts/product/create.ts
import { z } from "zod";

export const CreateProductRequest = z.object({
  name: z.string().min(1).max(120),
  priceCents: z.number().int().min(0).max(10_000_000),
  description: z.string().max(2000).optional(),
  tags: z.array(z.string().regex(/^[a-z0-9-]+$/)).max(20).default([]),
}).strict();    // strict() rejects unknown keys — no silent typos

export const CreateProductResponse = z.object({
  id: z.string().uuid(),
  name: z.string(),
  priceCents: z.number().int(),
  createdAt: z.string().datetime(),
});

export type CreateProductRequest = z.infer<typeof CreateProductRequest>;
export type CreateProductResponse = z.infer<typeof CreateProductResponse>;
```

## Server enforcement (Hono example)

```ts
import { zValidator } from "@hono/zod-validator";
import { CreateProductRequest, CreateProductResponse } from "../contracts/product/create.js";

products.post(
  "/",
  zValidator("json", CreateProductRequest, (result, c) => {
    if (!result.success) return c.json({ error: result.error.flatten() }, 422);
  }),
  async (c) => {
    const body = c.req.valid("json");
    const created = await db.createProduct(body);
    const out = CreateProductResponse.parse(created);  // server-side response check in dev
    return c.json(out, 201);
  },
);
```

## OpenAPI generation

```ts
import { OpenApiGeneratorV3, OpenAPIRegistry } from "zod-to-openapi";
import { CreateProductRequest, CreateProductResponse } from "./contracts/product/create.js";

const registry = new OpenAPIRegistry();
registry.registerPath({
  method: "post",
  path: "/v1/products",
  request: { body: { content: { "application/json": { schema: CreateProductRequest } } } },
  responses: {
    201: { description: "Created", content: { "application/json": { schema: CreateProductResponse } } },
  },
});
const doc = new OpenApiGeneratorV3(registry.definitions).generateDocument({
  openapi: "3.0.0",
  info: { title: "Products API", version: "1.0.0" },
});
```

## Reusing schemas as AI tool-use specs

Same schema works for Anthropic / OpenAI tool definitions — convert with `zod-to-json-schema`:

```ts
import { zodToJsonSchema } from "zod-to-json-schema";
const tool = {
  name: "create_product",
  description: CreateProductRequest.description ?? "Create a product",
  input_schema: zodToJsonSchema(CreateProductRequest, { target: "openApi3" }),
};
```

## Anti-patterns

- ❌ Defining the request shape twice (one schema for validation, one TS interface for typing) — they drift.
- ❌ Using `z.object({}).passthrough()` everywhere — defeats the point of contracts. Use `strict()` by default; `passthrough()` only when proxying.
- ❌ Validating only at the controller — also validate the response in dev/CI to catch drift between handler and contract.
- ❌ Putting Zod schemas inside route handlers — co-locate per endpoint in `contracts/` so the OpenAPI generator can find them.
- ❌ Using `z.coerce.number()` on user input without bounds — `coerce` will turn `""` into `0` silently.

## Quality gates

- 100% of routes use `zValidator` (or framework equivalent); no hand-rolled validation.
- OpenAPI doc regenerates in CI; PRs that change a contract show the diff.
- Generated client compiles; no `any` in the types.
