跳到主要内容
Zomer Gregorio

Zomer Gregorio

软件工程师

简历
语言
博客

Hono Type-Safe API Contracts: How to Share Routes and Types with RPC Clients

· TypeScript · Node.js · Hono · API Design · OpenAPI

Learn how to build end-to-end type-safe REST APIs using Hono and Z OpenAPI. Create unified schema definitions that generate both server validation middleware and client-side RPC types without code generation steps.

获取更新

每当我发布新内容时,你会收到一条简短通知。你的电子邮件或浏览器订阅信息仅用于发送这些更新,并可随时取消订阅;无需账户或跟踪档案。

开始接收通知前,需要通过确认邮件完成验证。

The Problem with Manual API Type Synchronization

Traditional REST API development in Node.js often suffers from a fundamental decoupling between server-side route handlers and client-side data fetching logic. When using frameworks like Express or Fastify, engineers frequently maintain duplicate TypeScript interfaces for request payloads, path parameters, and response schemas. Even when sharing code in a monorepo, manual sync drifts over time, leading to runtime errors that TypeScript cannot catch at build time.

Code generation tools like openapi-generator or trpc offer solutions, but bring distinct tradeoffs. OpenAPI code generators introduce complex build scripts and file-emitting steps into the CI/CD pipeline. Frameworks like tRPC solve type safety elegantly but abandon standard HTTP semantics, REST routing, and OpenAPI ecosystem compatibility, making third-party integration and public developer portals more difficult to maintain.

Hono solves this problem by using TypeScript's type system to infer RPC client types directly from the server's route definitions. By leveraging @hono/zod-openapi, you can construct an API that enforces runtime validation, generates valid OpenAPI 3.1 specifications, and exports strict, inference-based client contracts without running build-time code generators.

Architecture of an Inference-Based API Contract

To achieve full type safety across the network boundary without explicit code generation, the application architecture relies on three distinct layers:

  1. Zod OpenAPI Schemas: Declarative schema definitions that attach HTTP metadata (descriptions, example payloads, location tags) to standard Zod validators.
  2. Hono Route Definitions: Typed route declarations combining path templates, HTTP methods, validation schemas, and expected HTTP status code responses.
  3. Hono Client (hc): A proxy-based HTTP client that imports the server application's compile-time type (AppType) to deliver autocomplete, payload type-checking, and URL parameter verification.
                    +----------------------------+
                    |   Zod OpenAPI Schemas     |
                    +-------------+--------------+
                                  |
                                  v
                    +----------------------------+
                    |   Hono Route Definitions   |
                    +-------------+--------------+
                                  |
                 +----------------+----------------+
                 |                                 |
                 v                                 v
    +------------------------+        +------------------------+
    |  Runtime Validation    |        |  AppType (TypeScript)  |
    |   & OpenAPI Spec Doc   |        +-----------+------------+
    +------------------------+                    |
                                                  v
                                      +------------------------+
                                      |    Hono RPC Client     |
                                      +------------------------+

Because AppType is a purely type-level export, importing it in front-end or microservice consumers adds zero bytes to the client runtime bundle.

Implementing the End-to-End Type-Safe Route

To demonstrate this pattern, we will build a production-grade user management endpoint using @hono/zod-openapi and hono in TypeScript.

1. Defining Request and Response Schemas

First, define the domain entities and HTTP input specifications using @hono/zod-openapi instead of vanilla Zod. This allows Hono to attach OpenAPI metadata directly to the type system.

// schemas/user.schema.ts
import { z } from '@hono/zod-openapi';
 
export const UserParamsSchema = z.object({
  id: z.string().min(1).openapi({
    param: {
      name: 'id',
      in: 'path',
    },
    example: 'usr_1024',
  }),
});
 
export const UserResponseSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.string().datetime(),
}).openapi('User');
 
export const ErrorResponseSchema = z.object({
  code: z.string(),
  message: z.string(),
}).openapi('ErrorResponse');

2. Creating the OpenAPI Route Configuration

Next, construct a decoupled route definition using createRoute. This step establishes the contract—including path parameters, body formats, and all possible response status codes—before implementing business logic.

// routes/user.route.ts
import { createRoute } from '@hono/zod-openapi';
import { UserParamsSchema, UserResponseSchema, ErrorResponseSchema } from '../schemas/user.schema';
 
export const getUserRoute = createRoute({
  method: 'get',
  path: '/users/{id}',
  request: {
    params: UserParamsSchema,
  },
  responses: {
    200: {
      content: {
        'application/json': {
          schema: UserResponseSchema,
        },
      },
      description: 'Retrieve the user entry by unique identifier',
    },
    404: {
      content: {
        'application/json': {
          schema: ErrorResponseSchema,
        },
      },
      description: 'User not found',
    },
  },
});

3. Registering the Router and Handlers

Instantiate OpenAPIHono on the server and bind the handler to the route contract. The server handler's arguments and return values will be type-checked strictly against the getUserRoute schema.

// app.ts
import { OpenAPIHono } from '@hono/zod-openapi';
import { getUserRoute } from './routes/user.route';
 
const app = new OpenAPIHono();
 
const routes = app.openapi(getUserRoute, async (c) => {
  const { id } = c.req.valid('param');
 
  if (id !== 'usr_1024') {
    return c.json({ code: 'NOT_FOUND', message: 'User does not exist' }, 404);
  }
 
  return c.json({
    id,
    name: 'Ada Lovelace',
    email: 'ada@example.com',
    createdAt: new Date().toISOString(),
  }, 200);
});
 
// Generate JSON spec automatically at runtime
app.doc('/doc', {
  openapi: '3.1.0',
  info: {
    version: '1.0.0',
    title: 'User Management Microservice',
  },
});
 
export type AppType = typeof routes;
export default app;

4. Consuming the API with the Client Proxy

On the client side, import the AppType type-definition and initialize the Hono RPC client (hc). The client mirrors the nested directory structure of your HTTP paths as chainable JavaScript methods.

// client.ts
import { hc } from 'hono/client';
import type { AppType } from './app';
 
// Initialize the client using the structural backend type
const client = hc<AppType>('https://api.internal.domain');
 
async function fetchUser(userId: string) {
  // Method, path parameters, and query parameters are fully type-checked
  const res = await client.users[':id'].$get({
    param: { id: userId },
  });
 
  if (res.status === 404) {
    const errorData = await res.json();
    console.error(`Error [${errorData.code}]: ${errorData.message}`);
    return null;
  }
 
  if (res.status === 200) {
    // The response body type is automatically inferred as UserResponseSchema
    const user = await res.json();
    console.log(`Retrieved User: ${user.name} (${user.email})`);
    return user;
  }
}

Edge Cases, Limitations, and Tradeoffs

While this pattern eliminates boilerplate code, production deployments require careful consideration of several technical constraints.

1. TypeScript Compiler Performance and Depth Limits

Inferring client structures directly from chained Hono routes relies heavily on complex conditional types and template literal types in TypeScript. As an application grows past 100 route definitions in a single app instance, tsc compilation time and IDE language-server latency can degrade significantly.

Mitigation: Split monolithic router definitions into modular child routers using standard Hono path mounting. Export localized route types rather than passing one massive AppType through a single root instance.

// Split modules into sub-apps
const userRoutes = new OpenAPIHono().openapi(getUserRoute, userHandler);
const orderRoutes = new OpenAPIHono().openapi(getOrderRoute, orderHandler);
 
export const app = new OpenAPIHono()
  .route('/users', userRoutes)
  .route('/orders', orderRoutes);
 
export type AppType = typeof app;

2. Cross-Repository Dependency Boundaries

If the client and server live in separate repositories, you cannot directly reference the server's AppType via relative paths. Importing type definitions from uncompiled .ts source files across repository boundaries often causes module resolution failures.

Mitigation: Publish a dedicated lightweight @org/api-contracts npm package containing only the TypeScript types emitted by the server build, or maintain a monorepo setup (e.g., Turborepo, Nx, or pnpm workspaces).

3. Handling Date Serializations and Complex Types

JSON network responses automatically serialize Date objects into ISO 8601 strings. If your backend schema defines a field as z.date(), Zod validation will pass on the backend, but the client receiving the payload via res.json() will process a string at runtime. Using raw z.date() without string transformation logic introduces subtle type mismatches on the client side.

Mitigation: Always model network payloads strictly around JSON-serializable types using z.string().datetime() or custom Zod pipelines (z.pipe()) to avoid type mismatches between server-side models and over-the-wire payloads.

4. Binary Data and Multipart Requests

Type inference works smoothly with application/json and application/x-www-form-urlencoded payloads, but handling multipart/form-data file uploads requires explicit Zod instance schemas using z.instanceof(File) or z.instanceof(Blob). Browsers natively support File and Blob, but server-side Node.js runtimes prior to modern v20+ releases require explicit polyfills or custom type casting.

Summary

Using Hono with @hono/zod-openapi enables a lightweight, type-safe API strategy that delivers end-to-end safety while strictly adhering to open web standards. By converting runtime Zod schemas into compile-time TypeScript contracts, teams avoid redundant code generation scripts, maintain clear OpenAPI documentation, and gain compile-time protection against API regressions across client and server boundaries.