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

Error schemas

We have documented success responses and learned how schemas become components. But there is a big gap in our spec right now. What happens when something goes wrong? Our API returns errors for invalid data, missing resources, and authentication failures, but the spec does not describe any of those error shapes. A client has no idea what a 400 or 404 response looks like.

That matters more than you might think. Clients spend more code handling errors than handling success. A successful response is straightforward: parse the JSON, use the data. But errors? The client needs to check the status code, parse the error body, extract messages, show the right UI, and decide whether to retry. All of that depends on knowing the exact shape of every error response.

Defining error schemas in Zod

Let’s add two Zod schemas to src/schemas.ts that describe our error shapes:

Code along
// Add to src/schemas.ts

export const ErrorSchema = z.object({
  error: z.object({
    code: z.string(),
    message: z.string(),
  }),
});

export const ValidationErrorSchema = z.object({
  error: z.object({
    code: z.literal("VALIDATION_ERROR"),
    message: z.string(),
    fields: z.record(z.string(), z.array(z.string())),
  }),
});

ErrorSchema is the generic error format. Every error response in our API has an error object with a code (a machine-readable string like "NOT_FOUND" or "UNAUTHORIZED") and a message (a human-readable explanation). The code is what clients use in their code to decide what to do. The message is what they might display to the user.

ValidationErrorSchema is for 400 responses when the client sends invalid data. It extends the generic error with a fields object. The z.record(z.string(), z.array(z.string())) type means it is an object where each key is a field name and each value is an array of error messages. So if the client sends a book with an empty title and an invalid genre, they get back exactly which fields failed and why.

What does z.literal("VALIDATION_ERROR") do? It tells the spec that the code field is not just any string. It is always the exact value "VALIDATION_ERROR". This helps clients distinguish validation errors from other error types programmatically.

Adding error responses to routes

Now let’s update the routes in src/app.ts to include error responses. Import the new schemas and update the response field on each route:

Code along
// Update imports in src/app.ts
import {
  CreateBookSchema,
  BookQuerySchema,
  BookSchema,
  BookListSchema,
  ErrorSchema,
  ValidationErrorSchema,
} from "./schemas.js";

// Update the routes array
const routes = [
  route.get("/v2/books", {
    request: { query: BookQuerySchema },
    response: {
      200: BookListSchema,
      400: ValidationErrorSchema,
    },
    resolve: (c) => { ... },
  }),

  route.get("/v2/books/:id", {
    request: { params: z.object({ id: z.uuid() }) },
    response: {
      200: BookSchema,
      400: ValidationErrorSchema,
      404: ErrorSchema,
    },
    resolve: (c) => { ... },
  }),

  route.post("/v2/books", {
    request: { body: CreateBookSchema },
    response: {
      201: BookSchema,
      400: ValidationErrorSchema,
    },
    resolve: (c) => { ... },
  }),
];

Each route now documents every status code it can return. GET /v2/books/:id can return a 200 (the book), a 400 (invalid ID format), or a 404 (book not found). POST /v2/books can return a 201 (created), or a 400 (validation failed). The resolve functions stay the same. The response field is for documentation only.

What the generated spec looks like

Restart the server and check the spec for POST /v2/books:

curl http://localhost:3000/openapi.json | jq '.paths["/v2/books"].post.responses'
{
  "201": {
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "properties": {
            "id": { "type": "string", "format": "uuid" },
            "title": { "type": "string" }
            // ... other Book fields
          },
          "required": ["id", "title", "author", "genre", "ratings", "createdAt"],
          "additionalProperties": false
        }
      }
    }
  },
  "400": {
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "properties": {
            "error": {
              "type": "object",
              "properties": {
                "code": { "const": "VALIDATION_ERROR" },
                "message": { "type": "string" },
                "fields": {
                  "type": "object",
                  "additionalProperties": {
                    "type": "array",
                    "items": { "type": "string" }
                  },
                  "additionalProperties": false
                }
              },
              "required": ["code", "message", "fields"],
              "additionalProperties": false
            }
          },
          "required": ["error"],
          "additionalProperties": false
        }
      }
    }
  }
}

And if you check the GET /v2/books/{id} responses, the 404 error has the generic Error shape inlined:

curl http://localhost:3000/openapi.json | jq '.paths["/v2/books/{id}"].get.responses["404"]'
{
  "content": {
    "application/json": {
      "schema": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": { "type": "string" },
              "message": { "type": "string" }
            },
            "required": ["code", "message"],
            "additionalProperties": false
          }
        },
        "required": ["error"],
        "additionalProperties": false
      }
    }
  }
}

A client reading this spec sees the exact shape of every error. For a 400 on POST /v2/books, they know to expect error.code, error.message, and error.fields with field-level messages. They can write one error handler that works for every validation error in the API, because the shape is consistent.

Notice that the ErrorSchema shape appears on the 404 for GET /v2/books/{id} and will later appear on the 401 for protected endpoints. The schema is inlined in each operation, so you will see the same structure repeated. But since it all comes from the same ErrorSchema Zod definition, it is always consistent. Change the Zod schema once, and every operation that uses it gets the updated shape.

The pattern

The approach is straightforward:

  1. Define your error shapes as Zod schemas in src/schemas.ts
  2. Add them to the response field on each route with the appropriate status codes
  3. @hectoday/openapi inlines the error schemas in each operation automatically

Every possible status code should be documented. If a client can get a 404 from your endpoint, they need to see it in the spec. Otherwise they get an unexpected error with an unknown shape, and their app crashes instead of showing a helpful message.

In the next section, we will go beyond endpoint descriptions and look at how to document authentication, organize endpoints with tags, and make the spec more human-friendly with descriptions and examples.

Exercises

Exercise 1: Add ErrorSchema and ValidationErrorSchema to src/schemas.ts. Update the routes to include error responses. Restart the server and verify the error schemas appear in the generated spec.

Exercise 2: Send an invalid request to POST /v2/books (omit the title field). Compare the actual error response to the ValidationError schema in the spec. Do they match?

Exercise 3: Request a book that does not exist: curl http://localhost:3000/v2/books/00000000-0000-0000-0000-000000000000. Compare the response to the Error schema in the spec.

Why use shared Zod schemas for error responses instead of defining error shapes separately per route?

← Zod to OpenAPI Authentication documentation →

© 2026 hectoday. All rights reserved.