hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses REST API Design with @hectoday/http

What Makes an API RESTful

  • APIs are contracts
  • Project setup
  • Resources, not actions

HTTP Methods

  • GET, POST, PUT, PATCH, DELETE
  • Idempotency
  • Method safety and side effects

Status Codes

  • The status codes that matter
  • Error responses

Resource Design

  • Modeling resources
  • Partial responses and field selection
  • Pagination
  • Filtering, sorting, and searching

API Lifecycle

  • Versioning
  • Content negotiation
  • Rate limiting and quotas

Advanced Patterns

  • Bulk operations
  • Long-running operations
  • HATEOAS and discoverability

Putting It All Together

  • API design checklist
  • Summary

Error responses

The problem with inconsistent errors

Right now, our bookstore API returns errors in slightly different shapes depending on which route you hit. Sometimes it’s { "error": "Not found" }. Sometimes it’s { "error": [...] } with an array of Zod issues. If you added more routes, you’d probably end up with even more variations.

This is a real problem for anyone consuming your API. Look at what happens when errors come back in different shapes:

{ "error": "Not found" }
{ "message": "Invalid email format", "code": 422 }
{ "errors": [{ "field": "title", "msg": "required" }] }
{ "detail": "Rate limit exceeded", "retry_after": 60 }

Every time a consumer hits a new endpoint, they have to figure out the error shape all over again. They write different parsing code for each one. Their error handling becomes a mess of special cases.

This is a design failure. Let’s fix it.

One format, everywhere

Every error your API returns should use the exact same shape:

interface ApiError {
  error: {
    code: string; // Machine-readable: "NOT_FOUND", "VALIDATION_ERROR"
    message: string; // Human-readable: "Book not found"
    details?: FieldError[]; // For validation errors
  };
}

interface FieldError {
  field: string; // "title", "email", "rating"
  message: string; // "Required", "Must be between 1 and 5"
}

The code is for machines. Your consumer’s code uses it to decide what to do: retry on RATE_LIMITED, show a form error on VALIDATION_ERROR, redirect to login on UNAUTHORIZED.

The message is for humans. It’s what you display in a toast notification or log to the console.

The details array is optional and shows up only for validation errors, where you need to tell the client which specific fields had problems.

Here’s what each type of error looks like with this format:

// 404
{
  "error": {
    "code": "NOT_FOUND",
    "message": "Book not found"
  }
}

// 400 validation
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "details": [
      { "field": "title", "message": "Required" },
      { "field": "rating", "message": "Must be between 1 and 5" }
    ]
  }
}

// 409 conflict
{
  "error": {
    "code": "DUPLICATE_ISBN",
    "message": "A book with ISBN 978-0684801223 already exists"
  }
}

// 429 rate limit
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Too many requests. Try again in 60 seconds."
  }
}

Same shape every time. The consumer writes one error parser, and it works for every endpoint.

Building the error helpers

Let’s create a set of helper functions so we don’t have to build these error objects by hand in every route:

// src/errors.ts

export function apiError(
  status: number,
  code: string,
  message: string,
  details?: { field: string; message: string }[],
): Response {
  const body: any = {
    error: { code, message },
  };

  if (details) {
    body.error.details = details;
  }

  return Response.json(body, { status });
}

export function notFound(resource: string): Response {
  return apiError(404, "NOT_FOUND", `${resource} not found`);
}

export function validationError(details: { field: string; message: string }[]): Response {
  return apiError(400, "VALIDATION_ERROR", "Request validation failed", details);
}

export function conflict(message: string): Response {
  return apiError(409, "CONFLICT", message);
}

The apiError function is the foundation. It takes a status code, a machine-readable code, a human-readable message, and optional field-level details. It wraps everything in the consistent shape and returns a Response.

The other functions are shortcuts. notFound("Book") returns a 404 with code NOT_FOUND and message "Book not found". conflict("A book with this ISBN already exists") returns a 409 with code CONFLICT. These save you from repeating the same patterns in every route handler.

Now our route handlers become much cleaner:

import { notFound, conflict } from "../errors.js";

route.get("/books/:id", {
  request: { params: z.object({ id: z.string() }) },
  resolve: (c) => {
    if (!c.input.ok) return fromZodIssues(c.input.issues);
    const { id } = c.input.params;
    const book = books.find((b) => b.id === id);
    if (!book) return notFound("Book");
    return Response.json(book);
  },
});

Compare that to what we had before: Response.json({ error: "Book not found" }, { status: 404 }). The helper is shorter, consistent, and impossible to get wrong.

Mapping Zod errors

We’re using Zod for request body validation, and Zod has its own error format. We need to transform Zod’s errors into our standard API error format:

// src/errors.ts

interface Issue {
  path: readonly (string | number)[];
  message: string;
}

export function fromZodIssues(issues: Issue[]): Response {
  const details = issues.map((issue) => ({
    field: issue.path.join("."),
    message: issue.message,
  }));
  return validationError(details);
}

We define a small Issue interface with just the two fields we actually use: path and message. This works with c.input.issues directly, without importing Zod’s internal types. All we need from each issue is where the error is and what went wrong.

The issue.path.join(".") part turns nested paths like ["author", "name"] into "author.name".

Use it in your route handlers:

route.post("/books", {
  request: { body: CreateBookBody },
  resolve: (c) => {
    if (!c.input.ok) return fromZodIssues(c.input.issues);
    // ... create the book
  },
});

Now every validation error, no matter which endpoint it comes from, has the same shape.

The Problem Details RFC

There’s an official standard for API error formatting: RFC 9457, called “Problem Details for HTTP APIs.” It defines a slightly different format:

{
  "type": "https://api.bookstore.com/errors/not-found",
  "title": "Book not found",
  "status": 404,
  "detail": "No book with ID book-999 exists.",
  "instance": "/books/book-999"
}

Our format is simpler, but the idea is the same. The RFC adds a type field (a URL identifying the error category) and an instance field (a URL identifying this specific occurrence).

Using the full RFC format is optional. What matters is the principle: pick a format and use it everywhere. Consistency is what makes your API pleasant to work with.

What’s next

We’ve nailed down the foundation: URLs, HTTP methods, status codes, and error formatting. Now it’s time to think about how resources relate to each other. Books have authors. Authors have books. Books have reviews. How should those relationships show up in the API? That’s the resource modeling lesson.

Exercises

Exercise 1: Implement the apiError, notFound, and validationError helpers. Refactor all your routes to use them.

Exercise 2: Add fromZodIssues to transform Zod validation errors. Create a book with invalid data and verify the error response uses the consistent format.

Exercise 3: Hit several different error cases (404, 400, 409) and verify every response has the same { error: { code, message } } shape.

Why do error responses need a machine-readable code in addition to a human-readable message?

← The status codes that matter Modeling resources →

© 2026 hectoday. All rights reserved.