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

Examples and descriptions

Our spec has schemas that describe every field’s type and constraints. A client reading it knows that authorId is a UUID string and that genre must be one of five values. But types alone do not tell the full story. A client sees authorId: { type: "string", format: "uuid" } and wonders: “Where do I get an author ID? Is it the same as the book ID? What happens if I use one that does not exist?”

Descriptions fill in the gaps that schemas leave behind. And in our code-first approach, the way to add descriptions is with Zod’s .describe() method.

Adding descriptions with z.describe()

Zod has a built-in .describe() method that attaches a description string to any schema. @hectoday/openapi reads these descriptions and includes them in the generated spec.

Let’s update BookSchema in src/schemas.ts:

Code along
export const BookSchema = z.object({
  id: z.uuid().describe("Unique book identifier, generated by the server"),
  title: z.string().describe("The book's display title"),
  author: z
    .object({
      id: z.uuid(),
      name: z.string(),
    })
    .describe("The book's author with ID and display name"),
  genre: z
    .enum(["fiction", "science-fiction", "fantasy", "non-fiction", "other"])
    .describe("The book's genre category. Used for filtering in list endpoints."),
  ratings: z
    .object({
      average: z
        .number()
        .nullable()
        .describe("Average rating from 1 to 5. Null if no reviews exist yet."),
      count: z.number().int().describe("Total number of reviews for this book"),
    })
    .describe("Aggregated review ratings"),
  createdAt: z.string().datetime().describe("When the book was added to the catalog (ISO 8601)"),
});

Each .describe() call adds a description field to the corresponding property in the generated JSON Schema. Restart the server and check the output:

curl http://localhost:3000/openapi.json | jq '.paths["/v2/books/{id}"].get.responses["200"].content["application/json"].schema.properties.id'
{
  "type": "string",
  "format": "uuid",
  "description": "Unique book identifier, generated by the server"
}

The description appears right next to the type information. In the documentation UI at /docs, these descriptions show up next to each field when a client expands a schema. They answer the questions that types alone cannot.

What makes a good description

A good description explains what the field means, not what type it is. Compare these:

// Bad: repeats the type, adds nothing
z.uuid().describe("A UUID string");

// Good: explains what it means and where it comes from
z.uuid().describe("Unique book identifier, generated by the server");

The good version tells the client two things: this ID is unique (so they can use it as a key), and it is generated by the server (so they should not send one when creating a book).

Here are a few more examples:

// Explains the range AND when it's null
z.number().nullable().describe("Average rating from 1 to 5. Null if no reviews exist yet.");

// Explains where to get valid values
z.uuid().describe("The UUID of an existing author. Get valid IDs from GET /v2/authors.");

// Explains the behavior
z.enum(["title", "createdAt", "rating"])
  .default("title")
  .describe("Sort order for results. Defaults to alphabetical by title.");

The goal is to answer the questions a developer would ask after reading the schema. If they can use your API without asking you a single question, your descriptions are doing their job.

Describing request schemas too

Descriptions are just as important on request schemas. Update CreateBookSchema:

Code along
export const CreateBookSchema = z.object({
  title: z
    .string()
    .trim()
    .min(1)
    .max(200)
    .describe("The book's title. Will be trimmed of whitespace."),
  authorId: z
    .uuid()
    .describe("The UUID of an existing author. The author must exist before creating a book."),
  genre: z
    .enum(["fiction", "science-fiction", "fantasy", "non-fiction", "other"])
    .describe("The book's genre category"),
  description: z
    .string()
    .max(5000)
    .optional()
    .describe("A longer description of the book. Optional, up to 5000 characters."),
});

Now a client reading the spec for POST /v2/books sees exactly what each field means, not just what type it is. The authorId description tells them they need to create an author first. The description field tells them it is optional and has a character limit.

Describing query parameters

The same technique works on query schemas:

Code along
export const BookQuerySchema = z.object({
  genre: z
    .enum(["fiction", "science-fiction", "fantasy", "non-fiction", "other"])
    .optional()
    .describe("Filter books by genre. Omit to return all genres."),
  sort: z
    .enum(["title", "createdAt", "rating"])
    .default("title")
    .describe("Sort order for results. Defaults to alphabetical by title."),
  page: z.coerce
    .number()
    .int()
    .min(1)
    .default(1)
    .describe("Page number for pagination, starting at 1"),
  limit: z.coerce
    .number()
    .int()
    .min(1)
    .max(100)
    .default(20)
    .describe("Number of books per page. Between 1 and 100."),
});

Check how these appear in the generated spec:

curl http://localhost:3000/openapi.json | jq '.paths["/v2/books"].get.parameters[0]'
{
  "in": "query",
  "name": "genre",
  "description": "Filter books by genre. Omit to return all genres.",
  "schema": {
    "type": "string",
    "enum": ["fiction", "science-fiction", "fantasy", "non-fiction", "other"]
  }
}

The description from .describe() appears directly on the parameter. In the documentation UI, this shows up as help text next to the parameter input field.

Describing error schemas

Do not forget to describe your error schemas too:

Code along
export const ErrorSchema = z.object({
  error: z.object({
    code: z.string().describe("Machine-readable error code like NOT_FOUND or UNAUTHORIZED"),
    message: z.string().describe("Human-readable explanation of what went wrong"),
  }),
});

export const ValidationErrorSchema = z.object({
  error: z.object({
    code: z.literal("VALIDATION_ERROR").describe("Always VALIDATION_ERROR for validation failures"),
    message: z.string().describe("Human-readable summary of the validation failure"),
    fields: z
      .record(z.string(), z.array(z.string()))
      .describe("Field-level errors. Keys are field names, values are arrays of error messages."),
  }),
});

Now a client looking at a 400 error response in the docs knows that fields maps field names to arrays of messages. They can write their form validation against this structure with confidence.

A note on examples

Descriptions explain what fields mean. Examples show what real data looks like. In the OpenAPI spec, you can add example values to schemas so documentation UIs display realistic sample data instead of placeholder types.

Our API already has something close to examples: the seed data in src/app.ts. When a client tries the “list books” endpoint in the docs UI, they get back real JSON with titles like “Kindred” and genres like “fiction.” That is often enough.

For cases where you want the spec itself to include example values (so they appear even without hitting the live API), you can add them through zod-openapi extensions on your Zod schemas. This is beyond what .describe() can do, but the seed data and the interactive docs UI at /docs cover most practical needs.

Verify in the docs UI

After adding descriptions to all your schemas, open http://localhost:3000/docs and browse through the endpoints. Every field now has a description explaining what it means. This is the difference between documentation that tells you “this field is a string” and documentation that tells you “this field is the UUID of an existing author, get valid IDs from GET /v2/authors.”

That wraps up Section 4. We have gone beyond basic endpoint descriptions and added authentication documentation, logical grouping with tags, and human-friendly descriptions. In the next section, we will make all of this available to clients by serving the spec and rendering interactive docs.

Exercises

Exercise 1: Add .describe() to every field in BookSchema and CreateBookSchema. Restart the server and check the generated spec.

Exercise 2: Add .describe() to every field in BookQuerySchema. Check how the descriptions appear on the parameters in the generated spec.

Exercise 3: Open http://localhost:3000/docs and browse through the endpoints. Read the descriptions. Would a developer be able to use your API without asking you any questions?

What makes a good field description in the spec?

← Tags and grouping Serving the spec →

© 2026 hectoday. All rights reserved.