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:
// 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:
// 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:
- Define your error shapes as Zod schemas in
src/schemas.ts - Add them to the
responsefield on each route with the appropriate status codes @hectoday/openapiinlines 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?