hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses API documentation with OpenAPI and @hectoday/http

Why documentation

  • Undocumented APIs are unusable
  • The OpenAPI standard
  • Project setup

Describing your API

  • Paths and operations
  • Request parameters
  • Request bodies
  • Responses

Schemas and reuse

  • Component schemas
  • Zod to OpenAPI
  • Error schemas

Beyond endpoints

  • Authentication documentation
  • Tags and grouping
  • Examples and descriptions

Serving and consuming

  • Serving the spec
  • Interactive docs with Scalar
  • Generating client SDKs

Wrapping up

  • Versioned specs
  • Checklist

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:

ZodOpenAPI
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?

← Component schemas Error schemas →

© 2026 hectoday. All rights reserved.