<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Chinedu Otutu - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Chinedu Otutu - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Wed, 26 Aug 2026 17:05:26 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/tutumantutu/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build Type-Safe APIs with Hono and Zod ]]>
                </title>
                <description>
                    <![CDATA[ If you've shipped a Node.js API before, you already know this pain: your TypeScript types say one thing, your runtime validation says another, and your OpenAPI docs quietly disagree with both. Someone ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-type-safe-apis-with-hono-and-zod/</link>
                <guid isPermaLink="false">6a8c4f18dbeb4eb3a4b2ed0d</guid>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ hono ]]>
                    </category>
                
                    <category>
                        <![CDATA[ zod ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ backend ]]>
                    </category>
                
                    <category>
                        <![CDATA[ webdev ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chinedu Otutu ]]>
                </dc:creator>
                <pubDate>Mon, 24 Aug 2026 14:03:04 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/ce251811-43ad-4e2f-9761-83b6240f718e.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've shipped a Node.js API before, you already know this pain: your TypeScript types say one thing, your runtime validation says another, and your OpenAPI docs quietly disagree with both.</p>
<p>Someone adds a field to an interface, and the schema never gets updated. The docs stay stale until a client files a bug. There's no compiler error or failing test. The three sources of truth just drift apart.</p>
<p>In this tutorial, you'll learn how to collapse those three concerns into one definition using <a href="https://hono.dev">Hono</a> and <a href="https://zod.dev">Zod</a>. You'll build the same patterns I use in production, including in <a href="https://github.com/otutukingsley/clipforge">ClipForge</a>, an open-source video processing toolkit I built and maintain. They help validation, types, and docs stay in sync by design.</p>
<p>By the end of this article, you should know how to:</p>
<ul>
<li><p>Define one Zod schema that handles runtime validation, TypeScript types, and OpenAPI docs</p>
</li>
<li><p>Structure routes so the contract and the handler stay separated</p>
</li>
<li><p>Keep database schemas and HTTP schemas as two deliberate layers</p>
</li>
<li><p>Return one consistent error shape from every failure path</p>
</li>
<li><p>Carry those same patterns from a small Tasks API into a real multi-service system</p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To get the most out of this article, you'll need to know:</p>
<ul>
<li><p>JavaScript and basic TypeScript</p>
</li>
<li><p>How REST APIs work (routes, request bodies, status codes)</p>
</li>
<li><p>A little Node.js (installing packages, running scripts)</p>
</li>
</ul>
<p>You don't need prior experience with Hono, Zod, or Drizzle.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-1-the-drift-problem">1. The Drift Problem</a></p>
</li>
<li><p><a href="#heading-2-what-is-hono">2. What Is Hono?</a></p>
</li>
<li><p><a href="#heading-3-what-is-zod">3. What Is Zod?</a></p>
</li>
<li><p><a href="#heading-4-one-schema-three-jobs">4. One Schema, Three Jobs</a></p>
</li>
<li><p><a href="#heading-5-how-to-set-up-the-project">5. How to Set Up the Project</a></p>
</li>
<li><p><a href="#heading-6-how-to-define-your-api-schemas">6. How to Define Your API Schemas</a></p>
</li>
<li><p><a href="#heading-7-how-to-separate-database-schemas-from-api-schemas">7. How to Separate Database Schemas from API Schemas</a></p>
</li>
<li><p><a href="#heading-8-how-to-define-routes-as-contracts">8. How to Define Routes as Contracts</a></p>
</li>
<li><p><a href="#heading-9-how-to-keep-handlers-thin">9. How to Keep Handlers Thin</a></p>
</li>
<li><p><a href="#heading-10-how-to-return-one-error-shape-everywhere">10. How to Return One Error Shape Everywhere</a></p>
</li>
<li><p><a href="#heading-11-how-to-generate-docs-that-cant-drift">11. How to Generate Docs That Can't Drift</a></p>
</li>
<li><p><a href="#heading-12-how-to-make-the-app-production-ready">12. How to Make the App Production-Ready</a></p>
</li>
<li><p><a href="#heading-13-how-these-patterns-scale-in-a-production-app">13. How These Patterns Scale in a Production App</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-1-the-drift-problem">1. The Drift Problem</h2>
<p>Most TypeScript APIs end up maintaining three separate descriptions of the same data:</p>
<ol>
<li><p><strong>Runtime validation</strong> checks that run when a request arrives</p>
</li>
<li><p><strong>TypeScript types</strong> <strong>shapes</strong> the compiler understands at build time</p>
</li>
<li><p><strong>API documentation</strong>, the contract you show to consumers</p>
</li>
</ol>
<p>Each one lives in a different file and updates on a different schedule. And none of them can see the others.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/1b34f0ca-6b0f-48c0-9282-7bc07d99f171.png" alt="Three API sources of truth often drifting out of sync: TypeScript types, runtime validation, and OpenAPI docs" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p><em>Figure 1: Three descriptions of the same data, maintained separately and often out of sync.</em></p>
<p>Hand-written interfaces disappear at runtime. Joi or Yup schemas validate data but don't give you types for free. OpenAPI files are usually edited by hand, if they're edited at all.</p>
<p>The fix is not "be more careful." The fix is one definition that produces all three outputs.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/df2d559a-6afb-469d-81cb-363ab54d0dba.png" alt="One Zod schema producing runtime validation, TypeScript types, and OpenAPI docs" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p><em>Figure 2: One schema definition produces three outputs.</em></p>
<p>That is what Hono and Zod give you when you use them together through <code>@hono/zod-openapi</code>.</p>
<h2 id="heading-2-what-is-hono">2. What Is Hono?</h2>
<p><a href="https://hono.dev">Hono</a> is a small, fast web framework built on Web Standard APIs, the same <code>Request</code> and <code>Response</code> primitives that run in Node.js, Deno, Bun, and Cloudflare Workers.</p>
<p>Compared with Express, that difference matters:</p>
<table>
<thead>
<tr>
<th>Express</th>
<th>Hono</th>
</tr>
</thead>
<tbody><tr>
<td>Node-specific <code>req</code> / <code>res</code></td>
<td>Web Standard APIs</td>
</tr>
<tr>
<td>Params are untyped strings</td>
<td>Params validated and typed with Zod</td>
</tr>
<tr>
<td>Validation is your problem</td>
<td>Route definition is the OpenAPI entry</td>
</tr>
<tr>
<td>Runs on Node only</td>
<td>Runs on Node, Bun, Deno, and the edge</td>
</tr>
</tbody></table>
<p>Hono's core is about 14kb. On Node it's roughly 5–7× faster than Express for the same workload. On Bun or Cloudflare Workers, the gap widens further because those runtimes are optimized for web standards.</p>
<p>For most CRUD APIs, your database is still the bottleneck. But at high concurrency, or on edge runtimes where cold starts matter, the framework gap is real.</p>
<p>More importantly for this tutorial: Hono's OpenAPI integration lets your route definition <em>be</em> the documentation.</p>
<h2 id="heading-3-what-is-zod">3. What Is Zod?</h2>
<p><a href="https://zod.dev">Zod</a> is a TypeScript-first schema validation library. You describe the shape of your data once. Zod then:</p>
<ol>
<li><p>Validates that shape at runtime</p>
</li>
<li><p>Infers the TypeScript type with <code>z.infer</code></p>
</li>
<li><p>Feeds OpenAPI docs when you attach <code>.openapi()</code> metadata</p>
</li>
</ol>
<p>With Joi or Yup, you usually validate at runtime and then hand-write a matching interface. That's two definitions again. Zod removes the second one.</p>
<pre><code class="language-typescript">import { z } from 'zod';

const createTaskSchema = z.object({
  title: z.string().min(1).max(120),
  status: z.enum(['todo', 'in_progress', 'done']).default('todo'),
});

type CreateTaskInput = z.infer&lt;typeof createTaskSchema&gt;;
// { title: string; status?: "todo" | "in_progress" | "done" }
</code></pre>
<p>Change the schema, and every callsite that depends on <code>CreateTaskInput</code> updates with it. TypeScript will tell you what broke.</p>
<h2 id="heading-4-one-schema-three-jobs">4. One Schema, Three Jobs</h2>
<p>Here's the mental model for the rest of the article:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/890719d0-6ab0-4529-ad07-565a7db9e604.png" alt="taskSchema feeding runtime validation, TypeScript types, and OpenAPI documentation" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p><em>Figure 3:</em> <code>taskSchema</code> <em>is the single source of truth for validation, types, and docs.</em></p>
<ol>
<li><p><strong>Runtime validation:</strong> bad data is rejected before it reaches your handler, with structured field errors instead of a stack trace</p>
</li>
<li><p><strong>TypeScript types:</strong> <code>z.infer&lt;typeof schema&gt;</code> is derived from the schema, not maintained beside it</p>
</li>
<li><p><strong>OpenAPI docs:</strong> <code>.openapi('Name')</code> registers the schema in the generated spec, so <code>/reference</code> stays current</p>
</li>
</ol>
<p>One definition with three outputs. Nothing to keep in sync by hand.</p>
<h2 id="heading-5-how-to-set-up-the-project">5. How to Set Up the Project</h2>
<p>We'll use the companion demo from <a href="https://github.com/otutukingsley/api-conf-demo">api-conf-demo</a>. It's a small Tasks API that shows the patterns cleanly. Later, we'll look at how the same ideas show up in ClipForge at a larger scale.</p>
<p>Clone the repo and install dependencies:</p>
<pre><code class="language-bash">git clone https://github.com/otutukingsley/api-conf-demo.git
cd api-conf-demo
npm install
</code></pre>
<p>Start the server:</p>
<pre><code class="language-bash">npm run dev
</code></pre>
<p>You should see the API on <code>http://localhost:8080</code>, with interactive docs at <code>/reference</code>.</p>
<p>The important folders look like this:</p>
<pre><code class="language-text">src/
├── db/schema/          # Persistence layer (Drizzle tables)
├── lib/schemas/        # HTTP contract layer (Zod + OpenAPI)
├── lib/errors/         # One error envelope for every failure
├── routes/tasks/       # Route contracts + handlers
├── services/           # Business logic and DB access
├── app.ts              # Middleware, routers, OpenAPI wiring
└── env.ts              # Zod-validated environment config
</code></pre>
<p>This is still MVC. The tooling is just better:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/bd701dc8-6f83-490a-905d-fbba09438154.png" alt="MVC-style layers with HTTP client, controller, contract schemas, service model, and database" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p><em>Figure 4: Still MVC. Routes and handlers as the controller, schemas as the contract, and services as the model.</em></p>
<ul>
<li><p><strong>Model</strong> services its own business logic and database access</p>
</li>
<li><p><strong>View / contract</strong> schemas define what data looks like at the DB and HTTP boundaries</p>
</li>
<li><p><strong>Controller</strong> routes declare the contract, handlers fulfill it</p>
</li>
</ul>
<p>A request through the demo API looks like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/5019c39c-8c45-46f4-8e68-b6388e4c9944.png" alt="Sequence diagram of a POST /tasks request through middleware, route contract, handler, service, and database" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p><em>Figure 5: Request flow through the demo API, including the validation failure path.</em></p>
<p>In that diagram, a request flows through the Client, Middleware, Route contract, Handler, Service, and DB. Middleware handles logging and CORS. The route contract validates the body with Zod.</p>
<p>If validation fails, the client gets a <code>422 ApiError</code> and the handler never runs. If it passes, the handler gets a typed <code>CreateTaskInput</code>, calls <code>TaskService.create()</code>, the service inserts and parses the row, and the client gets <code>201</code> with the Task JSON.</p>
<h2 id="heading-6-how-to-define-your-api-schemas">6. How to Define Your API Schemas</h2>
<p>Start with the HTTP contract in <code>src/lib/schemas/task.ts</code>:</p>
<pre><code class="language-typescript">import { z } from '@hono/zod-openapi';

export const taskStatusSchema = z
  .enum(['todo', 'in_progress', 'done'])
  .openapi('TaskStatus');

export const taskSchema = z
  .object({
    id: z.string().uuid().openapi({
      example: '8e2c9f0a-2222-4a5a-9c3e-1a2b3c4d5e6f',
    }),
    title: z.string().min(1).max(120).openapi({
      example: 'Write the talk abstract',
    }),
    description: z.string().max(2000).nullable().openapi({
      example: 'Cover Hono + Zod patterns',
    }),
    status: taskStatusSchema.default('todo'),
    dueDate: z.string().date().nullable().openapi({
      example: '2026-07-15',
    }),
    createdAt: z.string().openapi({ example: '2026-06-28 10:15:00' }),
    updatedAt: z.string().openapi({ example: '2026-06-28 10:15:00' }),
  })
  .openapi('Task');

export type Task = z.infer&lt;typeof taskSchema&gt;;
</code></pre>
<p>That one object is now the runtime validator, TypeScript type, and an OpenAPI component named <code>Task</code>.</p>
<p>Request schemas should derive from the same base instead of being redeclared:</p>
<pre><code class="language-typescript">export const createTaskSchema = taskSchema
  .pick({ title: true, description: true, status: true, dueDate: true })
  .partial({ description: true, status: true, dueDate: true })
  .openapi('CreateTask');

export const updateTaskSchema = createTaskSchema
  .partial()
  .openapi('UpdateTask');

export type CreateTaskInput = z.infer&lt;typeof createTaskSchema&gt;;
export type UpdateTaskInput = z.infer&lt;typeof updateTaskSchema&gt;;
</code></pre>
<p><code>CreateTask</code> never includes <code>id</code>, because clients don't send one. <code>UpdateTask</code> makes every field optional, because a <code>PATCH</code> can touch any subset.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/2f7cd7b3-1f40-4102-83f9-2c682eecc301.png" alt="taskSchema deriving createTaskSchema and updateTaskSchema" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p><em>Figure 6: Request schemas derive from the same base resource schema.</em></p>
<p>We have three contracts with one source.</p>
<h2 id="heading-7-how-to-separate-database-schemas-from-api-schemas">7. How to Separate Database Schemas from API Schemas</h2>
<p>It's tempting to treat the database row and the API response as the same shape. In a tiny demo, they often look identical. In production, they diverge.</p>
<p>Keep them in separate files on purpose:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/7a80d075-4f35-467a-8470-3d87448a5982.png" alt="Database persistence schemas mapped to HTTP contract schemas in the service layer" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p><em>Figure 7: Keep database schemas and HTTP schemas as two deliberate layers.</em></p>
<p>Figure 7 has two panels connected by an arrow labeled "map" in the service.</p>
<p>Left pane <code>src/db/schema/</code> (persistence):</p>
<ul>
<li><p>Starts from a Drizzle table definition</p>
</li>
<li><p>That table drives SQL migrations</p>
</li>
<li><p>It also produces drizzle-zod schemas for parsing rows</p>
</li>
<li><p>And row insert/select types for TypeScript at the database boundary</p>
</li>
</ul>
<p>Right panel <code>src/lib/schemas/</code> (HTTP contract):</p>
<ul>
<li><p>Uses Zod + <code>.openapi()</code> as the public contract</p>
</li>
<li><p>Defines request bodies / params clients may send</p>
</li>
<li><p>Defines response bodies clients receive</p>
</li>
<li><p>Registers OpenAPI components used by <code>/doc</code> and <code>/reference</code></p>
</li>
</ul>
<p>The arrow in the middle matters: the database shape isn't automatically the API shape. The <strong>service</strong> maps between them. That's why an internal column can exist on the left without becoming part of the HTTP contract on the right.</p>
<p>In short:</p>
<ul>
<li><p><code>src/db/schema/</code>: what a row looks like in the database</p>
</li>
<li><p><code>src/lib/schemas/</code>: what the HTTP contract looks like</p>
</li>
</ul>
<p>Here's the Drizzle table from the demo:</p>
<pre><code class="language-typescript">import { sql } from 'drizzle-orm';
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { z } from 'zod';

export const taskStatusValues = ['todo', 'in_progress', 'done'] as const;

export const tasks = sqliteTable('tasks', {
  id: text('id').primaryKey(),
  title: text('title').notNull(),
  description: text('description'),
  status: text('status', { enum: taskStatusValues }).notNull().default('todo'),
  dueDate: text('due_date'),
  createdAt: text('created_at')
    .notNull()
    .default(sql`(current_timestamp)`),
  updatedAt: text('updated_at')
    .notNull()
    .default(sql`(current_timestamp)`),
});

export const selectTaskSchema = createSelectSchema(tasks, {
  status: z.enum(taskStatusValues),
});

export const insertTaskSchema = createInsertSchema(tasks, {
  id: () =&gt; z.string().uuid().optional(),
  title: () =&gt; z.string().min(1).max(120),
  description: () =&gt; z.string().max(2000).nullable().optional(),
  status: z.enum(taskStatusValues).optional(),
  dueDate: () =&gt; z.string().date().nullable().optional(),
});
</code></pre>
<p>One table definition gives you three outputs:</p>
<ol>
<li><p><strong>TypeScript types</strong> inferred from the columns</p>
</li>
<li><p><strong>SQL migrations</strong> generated with <code>drizzle-kit</code></p>
</li>
<li><p><strong>Zod schemas</strong> via <code>drizzle-zod</code></p>
</li>
</ol>
<p>That's useful at the database boundary. It's not a replacement for your HTTP schemas.</p>
<p>The moment you add an internal <code>archivedAt</code> column, or a computed field the API returns that's not a column, the split pays for itself. Divergence becomes a normal change instead of a painful refactor.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/b055d171-05a7-4661-8a52-73db3f28c8b5.png" alt="Service mapping a database row with internal fields to a public API response" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p><em>Figure 8: The service decides what the client may see.</em></p>
<h2 id="heading-8-how-to-define-routes-as-contracts">8. How to Define Routes as Contracts</h2>
<p>In this architecture, a route doesn't "just handle a request." A route declares the contract: method, path, request schemas, and response schemas.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/b8a19630-7447-4a9e-a643-3c950d7a88cc.png" alt="Route definition acting as the API contract fulfilled by a handler" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p><em>Figure 9: The route declares the contract. The handler fulfills it.</em></p>
<pre><code class="language-typescript">import { createRoute, z } from '@hono/zod-openapi';
import * as HttpStatusCodes from '@/lib/http-status-codes';
import { jsonContent } from '@/lib/openapi/json-content';
import { jsonApiErrorContent } from '@/lib/openapi/error-schema';
import {
  createTaskSchema,
  taskParamsSchema,
  taskSchema,
} from '@/lib/schemas/task';

export const createTask = createRoute({
  tags: ['Tasks'],
  method: 'post',
  path: '/tasks',
  summary: 'Create a task',
  request: {
    body: jsonContent(createTaskSchema, 'The task to create'),
  },
  responses: {
    [HttpStatusCodes.CREATED]: jsonContent(taskSchema, 'The created task'),
    [HttpStatusCodes.UNPROCESSABLE_ENTITY]:
      jsonApiErrorContent('Validation error'),
    [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonApiErrorContent(
      'Internal server error',
    ),
  },
});

export const getTask = createRoute({
  tags: ['Tasks'],
  method: 'get',
  path: '/tasks/{id}',
  summary: 'Get a task by ID',
  request: {
    params: taskParamsSchema,
  },
  responses: {
    [HttpStatusCodes.OK]: jsonContent(taskSchema, 'The requested task'),
    [HttpStatusCodes.NOT_FOUND]: jsonApiErrorContent('Task not found'),
    [HttpStatusCodes.UNPROCESSABLE_ENTITY]:
      jsonApiErrorContent('Validation error'),
    [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonApiErrorContent(
      'Internal server error',
    ),
  },
});
</code></pre>
<p>A small helper keeps response boilerplate readable:</p>
<pre><code class="language-typescript">export function jsonContent&lt;T extends z.ZodTypeAny&gt;(
  schema: T,
  description: string,
) {
  return {
    content: {
      'application/json': { schema },
    },
    description,
  };
}
</code></pre>
<p>Named status constants replace magic numbers. You use the same constants as object keys in the route and as switch cases in the handler. That makes status codes searchable and consistent.</p>
<h2 id="heading-9-how-to-keep-handlers-thin">9. How to Keep Handlers Thin</h2>
<p>Once the route defines the contract, the handler only has to fulfill it.</p>
<pre><code class="language-typescript">export const getTask: AppRouteHandler&lt;GetTaskRoute&gt; = (c) =&gt; {
  try {
    const { id } = c.req.valid('param');
    return c.json(TaskService.get(id), HttpStatusCodes.OK);
  } catch (error) {
    const apiError = ApiError.parse(error);
    switch (apiError.statusCode) {
      case HttpStatusCodes.NOT_FOUND:
      case HttpStatusCodes.UNPROCESSABLE_ENTITY:
        return c.json(apiError.toResponseBody(), apiError.statusCode);
      default:
        return c.json(
          apiError.toResponseBody(),
          HttpStatusCodes.INTERNAL_SERVER_ERROR,
        );
    }
  }
};

export const createTask: AppRouteHandler&lt;CreateTaskRoute&gt; = (c) =&gt; {
  try {
    const body = c.req.valid('json');
    return c.json(TaskService.create(body), HttpStatusCodes.CREATED);
  } catch (error) {
    const apiError = ApiError.parse(error);
    switch (apiError.statusCode) {
      case HttpStatusCodes.UNPROCESSABLE_ENTITY:
        return c.json(apiError.toResponseBody(), apiError.statusCode);
      default:
        return c.json(
          apiError.toResponseBody(),
          HttpStatusCodes.INTERNAL_SERVER_ERROR,
        );
    }
  }
};
</code></pre>
<p>Notice what's <em>not</em> in the handler:</p>
<ul>
<li><p>No manual parsing of params or bodies</p>
</li>
<li><p>No casting to <code>any</code></p>
</li>
<li><p>No business logic</p>
</li>
</ul>
<p><code>c.req.valid('param')</code> and <code>c.req.valid('json')</code> are already validated and typed. The service owns the database work:</p>
<pre><code class="language-typescript">get(id: string): Task {
  try {
    const row = db.select().from(tasks).where(eq(tasks.id, id)).get();
    if (!row) throw new NotFoundError(`Task ${id} not found`);
    return selectTaskSchema.parse(row);
  } catch (error) {
    throw ApiError.parse(error);
  }
},
</code></pre>
<p>The service wraps its body in one <code>try/catch</code> and normalizes every failure through <code>ApiError.parse()</code>. Zod errors, custom domain errors, and unexpected driver errors all become one typed shape.</p>
<h2 id="heading-10-how-to-return-one-error-shape-everywhere">10. How to Return One Error Shape Everywhere</h2>
<p>Clients should never guess whether an error looks like <code>{ message }</code>, <code>{ error }</code>, or a raw stack trace.</p>
<p>In the demo, every failure becomes one envelope:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/85080f92-0fde-45cf-9b2e-eeb4f7ec5ef5.png" alt="Different error sources normalized through ApiError.parse into one JSON envelope" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p><em>Figure 10: Every failure path becomes one predictable error shape.</em></p>
<p>The factory that creates routers bakes that behavior in with <code>defaultHook</code>:</p>
<pre><code class="language-typescript">export function createRouter() {
  return new OpenAPIHono({
    defaultHook: (result, c) =&gt; {
      if (!result.success) {
        const apiError = ApiError.parse(result.error);
        return c.json(apiError.toResponseBody(), apiError.statusCode);
      }
    },
  });
}
</code></pre>
<p>Every router goes through that factory. A bad UUID in a path param, a missing field in a POST body, or an invalid enum in a query string all produce the same response shape before the handler runs.</p>
<p><code>ApiError.parse()</code> is the second half of the pattern:</p>
<pre><code class="language-typescript">public static parse(error: unknown): ApiError {
  if (error instanceof ApiError) return error;

  if (error instanceof ZodError) {
    return new ApiError('Validation error', {
      statusCode: 422,
      errors: error.flatten().fieldErrors,
    });
  }

  return new ApiError('Internal server error', { statusCode: 500 });
}
</code></pre>
<p>Handlers still use an explicit <code>switch</code> on status codes. That's intentional. Each route documents exactly which statuses it can return. A handler that should never emit <code>403</code> doesn't have <code>403</code> sitting in a shared helper's defaults.</p>
<p>The repetition is the point. Explicit beats clever here.</p>
<h2 id="heading-11-how-to-generate-docs-that-cant-drift">11. How to Generate Docs That Can't Drift</h2>
<p>Because schemas are attached to route definitions, OpenAPI becomes a byproduct of the code instead of a separate chore.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/aac24756-84db-48f2-936a-3370c36b3dd2.png" alt="Zod schemas flowing into route definitions, OpenAPI JSON, and Scalar reference UI" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p><em>Figure 11: Docs are generated from the same route definitions as the runtime code.</em></p>
<pre><code class="language-typescript">export function configureOpenAPI(app: OpenAPIHono) {
  app.doc('/doc', {
    openapi: '3.0.0',
    info: {
      title: 'Bulletproof Tasks API',
      version: '1.0.0',
    },
  });

  app.get(
    '/reference',
    apiReference({
      spec: { url: '/doc' },
      theme: 'kepler',
      layout: 'modern',
      pageTitle: 'Bulletproof Tasks API',
    }),
  );
}
</code></pre>
<ul>
<li><p><code>GET /doc</code> returns the raw OpenAPI JSON</p>
</li>
<li><p><code>GET /reference</code> serves an interactive Scalar explorer</p>
</li>
</ul>
<p>When you change a schema or a response code in the route file, the docs update with it. There's no second docs step to forget.</p>
<h2 id="heading-12-how-to-make-the-app-production-ready">12. How to Make the App Production-Ready</h2>
<p>Type safety at the request boundary isn't enough. Configuration and process lifecycle need the same discipline.</p>
<h3 id="heading-validate-environment-variables-at-boot">Validate Environment Variables at Boot</h3>
<pre><code class="language-typescript">import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z
    .enum(['development', 'test', 'production'])
    .default('development'),
  LOG_LEVEL: z
    .enum(['silent', 'debug', 'info', 'warn', 'error', 'fatal'])
    .default('info'),
  PORT: z.coerce.number().default(8080),
  DATABASE_URL: z.string().default('tasks.db'),
});

export const env = envSchema.parse(process.env);
</code></pre>
<p>If a required value is missing or malformed, the process exits immediately with a clear Zod error. That's much better than discovering <code>undefined</code> three requests into production.</p>
<h3 id="heading-prefer-structured-logging">Prefer Structured Logging</h3>
<p>The demo uses Pino through <code>hono-pino</code> instead of a one-line console logger. In production you want JSON logs. In development you want something readable. One middleware can do both, and every request can carry a UUID.</p>
<h3 id="heading-shut-down-cleanly">Shut Down Cleanly</h3>
<p>Docker and process managers send <code>SIGTERM</code> before they kill a process. Handle it. Close the HTTP server, then close the database handle, then exit. Without that, a SQLite file (or a Postgres connection pool) can be left in a dirty state.</p>
<h3 id="heading-keep-the-app-runtime-portable">Keep the App Runtime-Portable</h3>
<p>Hono only depends on Web Standard APIs inside <code>app</code>. That means the same application object can run on Node:</p>
<pre><code class="language-typescript">import { serve } from '@hono/node-server';
import { app } from '@/app';

serve({ fetch: app.fetch, port: env.PORT });
</code></pre>
<p>Or on an edge runtime with almost nothing else:</p>
<pre><code class="language-typescript">import { app } from '@/app';

export default app;
</code></pre>
<p>That;s not a simplified example. That's the whole adapter.</p>
<h2 id="heading-13-how-these-patterns-scale-in-a-production-app">13. How These Patterns Scale in a Production App</h2>
<p>A Tasks API is a good teaching surface. Production systems are messier: you have uploads, background jobs, multiple packages, and longer-lived workflows.</p>
<p><a href="https://github.com/otutukingsley/clipforge">ClipForge</a> is a helpful example of a production app where those same ideas show up at scale. It's a self-hostable video processing toolkit built with Node.js, Hono, Zod, Drizzle, BullMQ, and Nuxt. You upload a video and get transcription, subtitles, summaries, chapter markers, and thumbnails.</p>
<p>The architecture looks like this:</p>
<pre><code class="language-text">apps/
├── api/        # Hono REST API
├── worker/     # BullMQ video processor
└── web/        # Nuxt frontend
packages/
├── shared/     # Zod schemas, constants, shared types
└── providers/  # OpenAI, Anthropic, Deepgram integrations
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/f341110f-ef93-4b79-b0d0-9b07aeed04d2.png" alt="ClipForge architecture with web, API, worker, shared schemas, Postgres, Redis, and AI providers" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p><em>Figure 12: ClipForge keeps the same contract-first core across web, API, and worker packages.</em></p>
<p>A video job moves through stages like uploading, validating, extracting audio, and so on without inventing new response shapes along the way:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/38487b12-26fd-4185-9ca1-48560a03699e.png" alt="ClipForge video job stages from uploading through complete, with a failed path" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p><em>Figure 13: A video job moves through typed stages without inventing new response shapes.</em></p>
<p>The important part isn't the video pipeline. The important part is that the API still follows the same contract-first rules.</p>
<h3 id="heading-shared-zod-schemas-across-packages">Shared Zod Schemas Across Packages</h3>
<p>ClipForge keeps HTTP contracts in <code>packages/shared</code>, so the API, worker, and web app share one vocabulary:</p>
<pre><code class="language-typescript">export const processingFeatureSchema = z.enum([
  'transcription',
  'subtitles',
  'summary',
  'chapters',
  'thumbnails',
]);

export const videoUploadSchema = z.object({
  title: z.string().min(3).max(200),
  description: z.string().max(2000).optional(),
  features: z
    .array(processingFeatureSchema)
    .default([
      'transcription',
      'subtitles',
      'summary',
      'chapters',
      'thumbnails',
    ]),
  priority: z.enum(['low', 'normal', 'high']).default('normal'),
});

export const jobStatusSchema = z.object({
  jobId: z.string(),
  title: z.string().optional(),
  state: jobStateSchema,
  stage: jobStageSchema,
  progress: z.number().min(0).max(100),
  result: jobResultSchema.optional(),
  failedReason: z.string().optional(),
  createdAt: z.string(),
  updatedAt: z.string(),
});
</code></pre>
<p>When the worker finishes a stage, it doesn't invent a new response shape. It updates state against the same schemas the API returns to the client.</p>
<h3 id="heading-routes-still-define-the-contract">Routes Still Define the Contract</h3>
<p>The upload route in ClipForge looks like the Tasks demo, just with multipart form data and rate limiting:</p>
<pre><code class="language-typescript">export const uploadVideo = createRoute({
  tags: ['videos'],
  method: 'post',
  path: '/videos/upload',
  middleware: [
    sessionMiddleware,
    apiKeysMiddleware,
    rateLimitMiddleware({
      windowMs: 60_000,
      max: 10,
      keyPrefix: 'ratelimit:upload',
    }),
  ] as const,
  request: {
    body: {
      content: {
        'multipart/form-data': {
          schema: z.object({
            file: z.instanceof(File),
            title: z.string().min(3).max(200),
            description: z.string().max(2000).optional(),
            features: z.string().optional(),
            priority: z.enum(['low', 'normal', 'high']).optional(),
          }),
        },
      },
    },
  },
  responses: {
    [HTTP_STATUS.ACCEPTED]: jsonContent(
      jobStatusSchema,
      'Video accepted for processing',
    ),
    [HTTP_STATUS.BAD_REQUEST]: jsonApiErrorContent('Invalid request'),
    [HTTP_STATUS.UNPROCESSABLE]: jsonApiErrorContent('Validation error'),
    [HTTP_STATUS.TOO_MANY_REQUESTS]: jsonApiErrorContent('Rate limit exceeded'),
  },
});
</code></pre>
<p>The handler validates input, writes a job row, enqueues BullMQ work, and returns <code>202 Accepted</code> with a typed job status. The heavy lifting happens in the worker. The API stays a contract layer.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63cd01e7cc7a92b9f77dc1e8/5d3d8157-b1c0-4f40-82f1-9023de847a24.png" alt="Sequence diagram of ClipForge video upload, queue processing, and job status polling" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p><em>Figure 14: The API accepts the upload and returns a typed job status while the worker does the heavy lifting.</em></p>
<p>Figure 14 is a sequence diagram with five participants: Web UI, Hono API, BullMQ, Worker, and Postgres. It shows the async boundary between “accept the upload” and “finish processing.” Here's every step in the diagram:</p>
<ol>
<li><p>The Web UI sends <code>POST /videos/upload</code> to the Hono API.</p>
</li>
<li><p>The Hono API runs <code>Zod + middleware</code> (validate input, session, rate limits, and related checks).</p>
</li>
<li><p>The Hono API performs <code>insert job row</code> in Postgres so the job exists before work starts.</p>
</li>
<li><p>The Hono API sends <code>enqueue process-video</code> to BullMQ.</p>
</li>
<li><p>The Hono API immediately returns <code>202 + jobStatusSchema</code> to the Web UI. At this point the client has a typed job status, but transcription hasn't finished yet.</p>
</li>
<li><p>BullMQ later delivers <code>process job</code> to the Worker.</p>
</li>
<li><p>The Worker runs <code>transcribe / analyze / thumbnails</code> as background work.</p>
</li>
<li><p>The Worker writes <code>update stage + result</code> back to Postgres as stages complete.</p>
</li>
<li><p>The Web UI polls with <code>GET /videos/jobs/{id}</code> against the Hono API.</p>
</li>
<li><p>The Hono API reads the latest job from Postgres and returns <code>jobStatusSchema</code> agai the same response shape as step 5, now with updated <code>stage</code>, <code>progress</code>, and eventually <code>result</code>.</p>
</li>
</ol>
<p>So the API stays a fast contract layer: accept, persist, enqueue, and respond. The worker owns the slow pipeline. The UI tracks progress by polling one share status schema.</p>
<h3 id="heading-database-schemas-still-stay-separate">Database Schemas Still Stay Separate</h3>
<p>ClipForge stores jobs in Postgres with Drizzle:</p>
<pre><code class="language-typescript">export const jobs = pgTable('jobs', {
  id: varchar('id', { length: 36 }).primaryKey(),
  sessionId: varchar('session_id', { length: 36 }).notNull(),
  state: jobStateEnum('state').notNull().default('waiting'),
  stage: jobStageEnum('stage').notNull().default('uploading'),
  progress: integer('progress').notNull().default(0),
  title: varchar('title', { length: 200 }).notNull(),
  features: jsonb('features').$type&lt;string[]&gt;().notNull(),
  provider: varchar('provider', { length: 50 }).notNull().default('openai'),
  result: jsonb('result').$type&lt;JobResult&gt;(),
  failedReason: text('failed_reason'),
  createdAt: timestamp('created_at', { withTimezone: true })
    .notNull()
    .defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true })
    .notNull()
    .defaultNow(),
});
</code></pre>
<p>The table has persistence concerns like <code>filePath</code> and <code>sessionId</code>. The public <code>jobStatusSchema</code> doesn't have to expose all of them. That's the same DB-versus-API split from the Tasks demo, applied to a real workflow.</p>
<h3 id="heading-the-same-production-habits-still-apply">The Same Production Habits Still Apply</h3>
<p>ClipForge validates environment variables with Zod on boot, configures OpenAPI and Scalar at <code>/doc</code> and <code>/reference</code>, normalizes failures through <code>ApiError</code>, and shuts down queues and Redis on <code>SIGTERM</code>.</p>
<p>The lesson is simple: if the small app is structured correctly, the large app doesn't need a different philosophy. It needs more packages, more middleware, and longer-running jobs on top of the same contract-first core.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Type-safe APIs aren't about adding more TypeScript. They're about removing duplicate sources of truth.</p>
<p>With Hono and Zod you can:</p>
<ul>
<li><p>validate requests at runtime</p>
</li>
<li><p>infer types automatically</p>
</li>
<li><p>generate OpenAPI docs from the same route definitions</p>
</li>
<li><p>keep database schemas and HTTP schemas intentionally separate</p>
</li>
<li><p>return one predictable error shape from every failure path</p>
</li>
</ul>
<p>Start with the <a href="https://github.com/otutukingsley/api-conf-demo">api-conf-demo</a> if you want the smallest readable version of these patterns. Then look at <a href="https://github.com/otutukingsley/clipforge">ClipForge</a> to see how the same ideas hold up when the API sits in front of uploads, queues, and multi-stage processing.</p>
<p>Once your route definition is the contract, your docs stop drifting, your handlers get thinner, and your clients get an API they can trust.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
