Web Engineering ·

Building Type-Safe APIs with Hono and TypeScript: Routing, Validation, and End-to-End Type Inference

A practical guide to building production-ready APIs with Hono and TypeScript. Covers route typing, Zod validation middleware, RPC-style client inference, error handling, and middleware composition with real code examples.

Building Type-Safe APIs with Hono and TypeScript: Routing, Validation, and End-to-End Type Inference

Most TypeScript HTTP frameworks make you choose between ergonomics and safety. Express gives you full control but leaves you writing req.body as SomeType and hoping. Fastify adds schema validation but the TypeScript integration feels bolted on. tRPC closes the gap but demands a full-stack commitment that not every project can make.

Hono sits in a different position. It is a small, fast router that runs on Cloudflare Workers, Deno, Bun, and Node.js without modification. More importantly, its type system is designed from the start to carry route shape information through the entire request lifecycle. You can define a route, validate the input, type the response, and hand a fully-typed client to your frontend with no code generation step.

This article covers how that actually works in practice, where the types break down, and what you give up compared to the alternatives.

Why Hono Is Worth the Learning Curve

Hono is not trying to be Express. The design goals are different: small bundle size (12KB for the core), zero dependencies, and first-class support for edge runtimes. If you are shipping to Cloudflare Workers or Deno Deploy, Express and Fastify are not options at all. Hono is.

But the runtime portability is table stakes. What makes Hono interesting for TypeScript shops is the generic-based routing system. Every app.get(), app.post(), and app.use() call participates in building a typed route registry. The types accumulate as you chain routes, and that accumulated type is what hono/client uses to generate a typed HTTP client.

This matters in a monorepo where your API and frontend share types. Instead of maintaining a separate OpenAPI spec or a tRPC router definition, you export the Hono app type and your frontend gets autocompletion for every route, every query parameter, and every response shape.

Route Typing with Generics

The core Hono type is Hono<Env, Schema, BasePath>. The Schema parameter is where route shapes accumulate. You rarely interact with it directly, but it is what makes the client inference work.

import { Hono } from "hono";

const app = new Hono();

app.get("/users/:id", (c) => {
  const id = c.req.param("id"); // string
  return c.json({ id, name: "Alice" });
});

This works but the response type is untyped. The client will see { id: string; name: string } but there is no enforcement that you actually return that shape. For lightweight internal APIs that is acceptable. For anything public-facing or with a dedicated frontend team, you want stronger guarantees.

The way to get them is through typed route definitions:

import { Hono } from "hono";
import { z } from "zod";
import { zValidator } from "@hono/zod-validator";

const userSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.string().datetime(),
});

type User = z.infer<typeof userSchema>;

const app = new Hono();

app.get("/users/:id", async (c) => {
  const id = c.req.param("id");
  // fetch user from DB
  const user: User = await getUser(id);
  return c.json(user);
});

The return type of c.json() is inferred from what you pass in. If getUser returns User, the response type carries that shape. If it returns something wider or narrower, TypeScript will surface the mismatch.

Request Validation with Zod Middleware

The @hono/zod-validator package is the standard way to validate incoming data. It integrates tightly with the context object so validated data is available without extra casting:

import { Hono } from "hono";
import { z } from "zod";
import { zValidator } from "@hono/zod-validator";

const createUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  role: z.enum(["admin", "member", "viewer"]).default("member"),
});

const querySchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

const app = new Hono();

app.get(
  "/users",
  zValidator("query", querySchema),
  async (c) => {
    const { page, limit } = c.req.valid("query"); // fully typed
    const users = await listUsers({ page, limit });
    return c.json({ users, page, limit });
  }
);

app.post(
  "/users",
  zValidator("json", createUserSchema),
  async (c) => {
    const body = c.req.valid("json"); // { name: string; email: string; role: "admin" | "member" | "viewer" }
    const user = await createUser(body);
    return c.json(user, 201);
  }
);

Notice c.req.valid("json") returns the parsed, coerced, and narrowed type directly. There is no as cast. If you pass "query" but validate "json", TypeScript catches it at compile time.

For path parameters, Zod validation works the same way:

const paramsSchema = z.object({
  id: z.string().uuid(),
});

app.get(
  "/users/:id",
  zValidator("param", paramsSchema),
  async (c) => {
    const { id } = c.req.valid("param"); // string (UUID validated at runtime)
    const user = await getUser(id);
    if (!user) return c.json({ error: "Not found" }, 404);
    return c.json(user);
  }
);

End-to-End Type Inference with hono/client

This is the feature that separates Hono from most alternatives. The hono/client package generates a typed HTTP client from your app’s type, with no code generation step.

In your API package, export the app type:

// apps/api/src/index.ts
import { Hono } from "hono";
import { usersRouter } from "./routes/users";

const app = new Hono()
  .route("/users", usersRouter)
  .route("/posts", postsRouter);

export type AppType = typeof app;
export default app;

In your frontend or another service, import the type and create the client:

// apps/web/src/lib/api.ts
import { hc } from "hono/client";
import type { AppType } from "../../api/src/index";

export const client = hc<AppType>("http://localhost:8787");

// usage
const response = await client.users.$get({
  query: { page: "1", limit: "20" },
});
const data = await response.json();
// data is typed as { users: User[]; page: number; limit: number }

The client methods mirror your route structure. client.users.$get() maps to GET /users. client.users[":id"].$get() maps to GET /users/:id. Query params, request bodies, and response shapes are all inferred from your route definitions.

One thing to know: query parameters travel as strings over HTTP. The client types them as strings even if your Zod schema coerces them to numbers. The z.coerce.number() call happens server-side. This is correct behavior but it means the client types for query params will always be string | undefined, not number. Do not work around this by mutating query types on the client side.

Middleware Composition and Context Typing

Hono middleware adds data to the context via c.set() and c.get(). To make this type-safe, you declare the context shape as the first generic on Hono:

type Env = {
  Variables: {
    userId: string;
    requestId: string;
  };
  Bindings: {
    DB: D1Database; // Cloudflare D1 binding
    KV: KVNamespace;
  };
};

const app = new Hono<Env>();

// auth middleware
const authMiddleware = createMiddleware<Env>(async (c, next) => {
  const token = c.req.header("Authorization")?.replace("Bearer ", "");
  if (!token) return c.json({ error: "Unauthorized" }, 401);

  const userId = await verifyToken(token);
  c.set("userId", userId); // type-checked against Variables
  await next();
});

app.use("/api/*", authMiddleware);

app.get("/api/profile", async (c) => {
  const userId = c.get("userId"); // string, not string | undefined
  const user = await getUser(userId);
  return c.json(user);
});

The Bindings key is specific to Cloudflare Workers. When you define your bindings in wrangler.toml, adding them to the Bindings type gives you typed access through c.env.DB, c.env.KV, etc. On Node.js or Bun, you typically use Variables only and access environment variables via process.env.

For router composition with separate files, each sub-router should share the same Env type or extend it:

// routes/users.ts
export const usersRouter = new Hono<Env>()
  .get("/", authMiddleware, listUsersHandler)
  .get("/:id", authMiddleware, zValidator("param", paramsSchema), getUserHandler)
  .post("/", authMiddleware, zValidator("json", createUserSchema), createUserHandler);

The chaining pattern is intentional. When you chain methods on a single Hono instance, the types compose. If you use separate app.get() calls on different lines, they also compose, but chaining makes the route type more explicit and enables better client inference.

Error Handling Patterns

Hono has a built-in error handler. Define it once and it catches any thrown errors or unhandled 500s:

import { HTTPException } from "hono/http-exception";

app.onError((err, c) => {
  if (err instanceof HTTPException) {
    return err.getResponse();
  }

  console.error(err);
  return c.json({ error: "Internal server error" }, 500);
});

app.notFound((c) => {
  return c.json({ error: "Not found" }, 404);
});

HTTPException is Hono’s typed exception class. You can throw it anywhere in a handler or middleware:

import { HTTPException } from "hono/http-exception";

async function getUserHandler(c: Context<Env>) {
  const { id } = c.req.valid("param");
  const user = await getUser(id);

  if (!user) {
    throw new HTTPException(404, { message: "User not found" });
  }

  return c.json(user);
}

For validation errors, @hono/zod-validator returns a 400 by default when the schema fails. You can override this with a custom hook:

app.post(
  "/users",
  zValidator("json", createUserSchema, (result, c) => {
    if (!result.success) {
      return c.json(
        {
          error: "Validation failed",
          issues: result.error.flatten().fieldErrors,
        },
        400
      );
    }
  }),
  createUserHandler
);

The custom hook pattern is also how you log validation failures or add request tracing IDs to error responses.

Testing

Hono provides a testClient utility for testing without starting a real server. It uses the same type inference as hc from hono/client:

// users.test.ts
import { testClient } from "hono/testing";
import { usersRouter } from "./routes/users";

const client = testClient(usersRouter);

describe("GET /users", () => {
  it("returns paginated users", async () => {
    const response = await client.index.$get({
      query: { page: "1", limit: "10" },
    });

    expect(response.status).toBe(200);
    const body = await response.json();
    expect(body.users).toHaveLength(10);
    expect(body.page).toBe(1);
  });

  it("rejects invalid query params", async () => {
    const response = await client.index.$get({
      query: { page: "abc", limit: "10" },
    });

    expect(response.status).toBe(400);
  });
});

For routes that depend on Cloudflare bindings, you can pass mock bindings as the second argument to testClient. For routes that depend on middleware setting context variables (like userId from auth), the simplest approach is to factor out the handler logic from the middleware dependency so you can test handlers in isolation, then write integration-level tests for the full middleware chain.

DX Comparison: Hono vs Express vs Fastify

DimensionExpressFastifyHono
Runtime portabilityNode.js onlyNode.js onlyNode, Bun, Deno, CF Workers, Deno Deploy
Request body typingManual castJSON schema + generics (verbose)Zod middleware, inferred
Response typingNonePartial (schema inference limited)Inferred from c.json() argument
End-to-end client typesNoNoYes, via hono/client
Middleware type safetyNoneDecorators (complex)Variables generic, createMiddleware
Bundle sizeLarge with ecosystemMediumSmall (12KB core)
Error handlingAd hocLifecycle hooksonError, HTTPException
Test utilitiesSupertest (external)Built-in injectBuilt-in testClient
Ecosystem maturityLargestLargeSmaller, growing fast

The Fastify comparison deserves a note. Fastify’s type system is genuinely capable, but it relies on JSON Schema for request/response validation and requires explicit generic annotations at the route level. The types work but the ergonomics are worse than Hono plus Zod. You also cannot easily share Fastify route types with a frontend client without a third-party plugin or code generation.

Express is in a different category. It is the right choice when ecosystem breadth matters more than type safety or when you are maintaining an existing codebase. Starting a new project on Express in 2026 means accepting that you will write type assertions by hand.

Production Considerations

A few things that do not show up in tutorials but matter in production:

Hono’s response type inference works through the chain of middleware and validators. If you break the chain (for example, by using a helper function that wraps c.json() without returning it), the type inference stops. Keep route handlers as close to the response call as possible or ensure helper functions return the Response type explicitly.

The hono/client package infers types at compile time from your app’s exported type. This means if your API and frontend are in separate repositories and you share the type via a published package, you need to republish every time you change a route signature. In a monorepo this is a non-issue. In a polyrepo, you are adding a publish step to your API deployment pipeline.

Streaming responses (c.stream(), c.streamText()) break the JSON inference model. The client cannot infer what is inside a stream. If you are building an LLM API that returns SSE or chunked JSON, type the stream events manually and document the contract separately.

Cloudflare Workers has a 1MB compressed size limit for Worker scripts. Hono’s small core helps here, but if you are importing a large Zod schema library or other heavy dependencies, watch your bundle size. Use wrangler build --outdir dist and inspect the output before deploying.

Closing

Hono’s type system is genuinely well-designed. The route generics, the validated context, and the hono/client inference work together in a way that reduces the class of bugs you catch only at runtime. The tradeoffs are real: smaller ecosystem, streaming type gaps, and the polyrepo distribution problem. But for greenfield projects targeting edge runtimes, or TypeScript monorepos where API and frontend share a codebase, it is a more complete solution than either Express or Fastify for the type-safety use case.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.