Zod to OpenAPI
Throughout this course, we have been looking at the generated spec and tracing each piece back to a Zod schema. By now, the pattern should feel familiar: every z.uuid() becomes { "type": "string", "format": "uuid" }, every .optional() removes a field from the required array, every .default() adds a "default" property.
But we have not looked at the full mapping in one place. In this lesson, we will lay out exactly how every Zod type translates to OpenAPI, and understand how @hectoday/openapi does the conversion under the hood using the zod-openapi library.
How Zod maps to OpenAPI
Every Zod type has an OpenAPI equivalent. Here is the complete mapping:
| Zod | OpenAPI |
|---|---|
z.string() | { "type": "string" } |
z.string().min(1).max(200) | { "type": "string", "minLength": 1, "maxLength": 200 } |
z.email() | { "type": "string", "format": "email" } |
z.uuid() | { "type": "string", "format": "uuid" } |
z.number().int() | { "type": "integer" } |
z.number().min(1).max(5) | { "type": "number", "minimum": 1, "maximum": 5 } |
z.boolean() | { "type": "boolean" } |
z.enum(["a", "b"]) | { "type": "string", "enum": ["a", "b"] } |
z.array(z.string()) | { "type": "array", "items": { "type": "string" } } |
z.object({ name: z.string() }) | { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"], "additionalProperties": false } |
.optional() | Field not in required array |
.nullable() | { "anyOf": [{ "type": "string" }, { "type": "null" }] } |
.default("x") | { "default": "x" } |
.describe("text") | { "description": "text" } |
z.literal("x") | { "const": "x" } |
z.record(z.string(), z.string()) | { "type": "object", "additionalProperties": { "type": "string" } } |
This is not magic. It is a straightforward translation. A Zod string with .min(1).max(200) becomes an OpenAPI string with "minLength": 1, "maxLength": 200. A Zod enum with three values becomes an OpenAPI enum with the same three values. The structures are different, but the information is identical.
How @hectoday/openapi does it
When you call openapi(routes, config), the package takes every request.body, request.query, request.params, and response Zod schema from your routes and converts them to JSON Schema. It uses the zod-openapi library under the hood, which handles the full range of Zod types: objects, arrays, enums, unions, optionals, defaults, and more.
Here is what happens in practice. You write this route:
route.post("/v2/books", {
request: {
body: z.object({
title: z.string().trim().min(1).max(200),
authorId: z.uuid(),
genre: z.enum(["fiction", "science-fiction", "fantasy", "non-fiction", "other"]),
description: z.string().max(5000).optional(),
}),
},
response: {
201: BookSchema,
},
resolve: (c) => {
// ...
},
}); And the generated spec includes this request body schema:
{
"type": "object",
"properties": {
"title": { "type": "string", "minLength": 1, "maxLength": 200 },
"authorId": { "type": "string", "format": "uuid" },
"genre": {
"type": "string",
"enum": ["fiction", "science-fiction", "fantasy", "non-fiction", "other"]
},
"description": { "type": "string", "maxLength": 5000 }
},
"required": ["title", "authorId", "genre"],
"additionalProperties": false
} You can verify this yourself:
curl http://localhost:3000/openapi.json | jq '.paths["/v2/books"].post.requestBody.content["application/json"].schema' Compare the generated JSON to the Zod schema above. The mapping is one-to-one. But you did not write the JSON by hand. It was generated from the same Zod schema that validates requests at runtime. One source of truth.
Understanding the conversion
Even though @hectoday/openapi handles the conversion for you, it helps to understand what is happening. Let’s walk through how each Zod type maps:
z.string() becomes "type": "string". When you add .min(1), it adds "minLength": 1. Each Zod constraint maps to a JSON Schema property. z.uuid() is its own type that becomes "type": "string", "format": "uuid".
z.number() becomes "type": "number". When you add .int(), the type changes to "integer". .min(1) becomes "minimum": 1.
z.object() becomes "type": "object" with "properties" and "required". Every field in the object that is not .optional() gets added to the required array. Optional fields are listed in properties but excluded from required.
z.enum(["fiction", "science-fiction"]) becomes "type": "string" with "enum": ["fiction", "science-fiction"].
.describe("text") adds a "description" property to the schema. We will use this in the examples and descriptions lesson to make the spec more human-friendly.
Understanding this mapping helps you write Zod schemas that produce clean, accurate specs. If you know that z.uuid() generates "format": "uuid" in the spec, you will use it consistently. If you know that .optional() removes a field from required, you will be deliberate about what is required and what is not.
Why this matters
The entire “docs that cannot go stale” approach depends on this conversion. Your Zod schemas are the single source of truth. They validate requests at runtime and generate documentation at the same time. There is no second file to update, no second schema to keep in sync.
In the next lesson, we will apply the same idea to error responses and see how to document them consistently across all your endpoints.
Exercises
Exercise 1: Add a Zod schema to a route’s request.body. Start the server and check the generated spec at /openapi.json. Trace each Zod constraint to its JSON Schema equivalent.
Exercise 2: Add a response schema for the 200 case on a GET endpoint. Check the generated spec. Notice how it appears in the responses section.
Exercise 3: Write a schema with optional fields, enums, and nested objects. Check the generated spec. Verify that required fields appear in the required array and optional fields do not.
Why generate OpenAPI schemas from Zod instead of writing them separately?