Building Type-Safe APIs with Hono and TypeScript
A practical guide to building fully type-safe REST APIs with Hono and TypeScript. Covers Hono's RPC client for end-to-end type safety, Zod schema validation, middleware patterns, error handling, testing strategies, and deployment to Cloudflare Workers, with comparisons to Express and Fastify approaches.
Express still works. But “works” and “catches your bugs at compile time” are different standards. The failure mode with Express and TypeScript is that you get type annotations on the surface: typed request handlers, typed route parameters, typed response helpers. The actual contract between server and client remains unverified until something breaks in production. You define req.params.userId as a string, but whether that string is a valid UUID is a runtime concern. The query parameter page arrives as a string and silently fails arithmetic. The response shape you documented in a comment drifts away from what the handler actually returns.
Hono takes a different approach. The type system is structural, not decorative. Route parameters flow through handlers as typed values derived from the route pattern. The response schema is part of the route definition. In RPC mode, the client imports your entire API surface from the server code without a code generation step or a separate schema file. The contract is the code.
This guide covers the full path: Hono’s route type system, Zod validation middleware, composable middleware patterns, structured error handling, the RPC client, testing strategies, and deployment to Cloudflare Workers. Where the tradeoffs with Express and Fastify are real, they are named explicitly.
Why Express Falls Short for Type-Safe APIs
The surface-level criticism of Express is unfair. With @types/express, route handlers are typed and the library is well understood. The problem is that the type safety is superficial and collapses at the boundaries that matter most.
Consider a typical Express route:
app.get('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id)
res.json(user)
})
TypeScript sees req.params.id as string. That is accurate but tells you nothing about whether id is present in the route pattern, whether the value has the shape you expect, or whether the response matches what clients rely on. Ask for req.params.userID (capital D) on a route that defines :userId (lowercase d) and you get undefined at runtime, not a compile error.
The usual solution is to layer libraries: express-validator for input validation, openapi-typescript-codegen for client types, zod for schema definitions. By the time you have end-to-end type safety in an Express app, you have assembled four or five libraries with no shared type model. Every boundary between them is a place where types can diverge from reality without a build-time signal.
Hono was designed with this problem as the constraint, not an afterthought. The type system flows from route definition to handler to client.
Setting Up a Hono Project
npm create hono@latest my-api
# select cloudflare-workers, node, or bun
cd my-api
npm install
The basic structure for a Cloudflare Workers deployment:
// src/index.ts
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('OK'))
export default app
For Node.js:
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
const app = new Hono()
serve({ fetch: app.fetch, port: 3000 })
The fetch-based interface is intentional. Hono uses the same Request/Response API as the browser Fetch API and Cloudflare Workers. The same application code runs on Workers, Node.js via the adapter, Deno, and Bun without modification. That portability has a real cost: some Node.js-specific libraries (filesystem access, native modules) are unavailable on edge runtimes. Design your application layer to avoid those at the start if Workers is a target.
How Route Types Work
Hono tracks route parameters, response types, and environment bindings through generic types at the router level. These accumulate as you define routes rather than requiring a separate type declaration.
import { Hono } from 'hono'
type Bindings = {
DB: D1Database
KV: KVNamespace
API_KEY: string
}
const app = new Hono<{ Bindings: Bindings }>()
app.get('/users/:id', (c) => {
// c.req.param('id') is typed as string
// c.env.DB is typed as D1Database — no casting needed
const id = c.req.param('id')
const db = c.env.DB
return c.json({ id })
})
The c context object carries the full generic type. c.req.param('id') returns string, not string | undefined, because Hono infers that :id is present in the matched route. Ask for a parameter that is not in the route definition and you get a type error, not a silent undefined at runtime.
The Variables type works alongside Bindings for middleware-set context values:
type Variables = {
userId: string
orgId: string
}
const app = new Hono<{ Bindings: Bindings; Variables: Variables }>()
// Middleware sets variables
app.use('/api/*', async (c, next) => {
const token = c.req.header('Authorization')
const payload = await verifyJWT(token, c.env.API_KEY)
c.set('userId', payload.sub)
c.set('orgId', payload.orgId)
await next()
})
// Handler reads them with full type safety
app.get('/api/profile', (c) => {
// c.get('userId') is typed as string, not string | undefined
const userId = c.get('userId')
return c.json({ userId })
})
Without the Variables type, c.get() returns unknown. With it, the return type is inferred from what you declared, and calls to c.get() for keys you have not declared are type errors.
Validation with Zod Middleware
Route parameter types are useful. Request body and query parameter types are where untyped APIs accumulate the most bugs in production. Hono’s @hono/zod-validator middleware runs a Zod schema against the request, makes the parsed result available on the context, and handles validation failures before your handler runs.
npm install zod @hono/zod-validator
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const app = new Hono()
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
role: z.enum(['admin', 'member', 'viewer']),
})
app.post(
'/users',
zValidator('json', createUserSchema),
async (c) => {
// data is fully typed: { email: string; name: string; role: 'admin' | 'member' | 'viewer' }
const data = c.req.valid('json')
const user = await db.users.create(data)
return c.json(user, 201)
}
)
c.req.valid('json') returns the Zod-parsed output type. If your schema transforms an input (for example, z.string().transform(s => s.trim())), the transformed type is what the handler receives. If validation fails, Hono returns a 400 before your handler runs.
You can customize validation failure responses:
app.post(
'/users',
zValidator('json', createUserSchema, (result, c) => {
if (!result.success) {
return c.json(
{
error: 'Validation failed',
issues: result.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
code: i.code,
})),
},
400
)
}
}),
async (c) => {
const data = c.req.valid('json')
// ...
}
)
Query parameters benefit from Zod coercion. Without it, arithmetic on query params produces silent NaN:
const listUsersQuery = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
role: z.enum(['admin', 'member', 'viewer']).optional(),
search: z.string().max(200).optional(),
})
app.get(
'/users',
zValidator('query', listUsersQuery),
async (c) => {
// page and limit are numbers, not strings
const { page, limit, role, search } = c.req.valid('query')
const offset = (page - 1) * limit
const users = await db.users.list({ offset, limit, role, search })
return c.json({ users, page, limit, total: users.length })
}
)
Without z.coerce.number(), a query parameter arriving as "20" makes offset = ("1" - 1) * "20" which is NaN. Express gives you the same string and leaves coercion as an exercise for the reader, which is why production APIs have so many parseInt calls scattered through handlers.
Middleware Patterns
Hono middleware is standard function composition. The key difference from Express is that middleware participates in the type system via the Variables mechanism covered earlier.
Authentication Middleware
import { Hono } from 'hono'
import { HTTPException } from 'hono/http-exception'
import { verify } from 'hono/jwt'
type Bindings = { JWT_SECRET: string }
type Variables = { user: { id: string; role: 'admin' | 'member' | 'viewer' } }
const app = new Hono<{ Bindings: Bindings; Variables: Variables }>()
const authMiddleware = async (
c: Context<{ Bindings: Bindings; Variables: Variables }>,
next: Next
) => {
const authHeader = c.req.header('Authorization')
if (!authHeader?.startsWith('Bearer ')) {
throw new HTTPException(401, { message: 'Missing authorization header' })
}
const token = authHeader.slice(7)
try {
const payload = await verify(token, c.env.JWT_SECRET)
c.set('user', {
id: payload.sub as string,
role: payload.role as 'admin' | 'member' | 'viewer',
})
await next()
} catch {
throw new HTTPException(401, { message: 'Invalid token' })
}
}
app.use('/api/*', authMiddleware)
Rate Limiting with KV
On Workers, rate limiting without an external service uses KV for sliding window counters:
const rateLimitMiddleware = (limit: number, windowSeconds: number) => {
return async (c: Context<{ Bindings: Bindings }>, next: Next) => {
const ip = c.req.header('CF-Connecting-IP') ?? 'unknown'
const key = `rate:${ip}:${Math.floor(Date.now() / (windowSeconds * 1000))}`
const current = await c.env.KV.get(key)
const count = current ? parseInt(current, 10) : 0
if (count >= limit) {
return c.json({ error: 'Rate limit exceeded' }, 429)
}
await c.env.KV.put(key, String(count + 1), {
expirationTtl: windowSeconds * 2,
})
await next()
}
}
app.use('/api/*', rateLimitMiddleware(100, 60)) // 100 req/min per IP
Request Timing and Logging
app.use('*', async (c, next) => {
const start = Date.now()
await next()
const duration = Date.now() - start
console.log(
JSON.stringify({
method: c.req.method,
path: c.req.path,
status: c.res.status,
durationMs: duration,
})
)
})
Route-Level Middleware Composition
One pattern that reduces boilerplate is composing middleware at the route factory level rather than applying them individually:
import { createMiddleware } from 'hono/factory'
const requireRole = (role: 'admin' | 'member') =>
createMiddleware<{ Variables: Variables }>(async (c, next) => {
const user = c.get('user')
if (!user || user.role !== role) {
throw new HTTPException(403, { message: 'Insufficient permissions' })
}
await next()
})
// Usage: only admin users can delete
app.delete('/users/:id', authMiddleware, requireRole('admin'), async (c) => {
const id = c.req.param('id')
await db.users.delete(id)
return c.body(null, 204)
})
Error Handling
Hono’s onError handler catches both explicit throws and unhandled promise rejections. The pattern that works in production separates domain errors from infrastructure errors:
import { Hono } from 'hono'
import { HTTPException } from 'hono/http-exception'
const app = new Hono()
app.onError((err, c) => {
if (err instanceof HTTPException) {
// HTTP exceptions have a pre-built response with status and message
return err.getResponse()
}
// Unexpected errors: log and return generic response
// On Workers, console.error routes to the Cloudflare logging pipeline
console.error(JSON.stringify({
message: err.message,
stack: err.stack,
path: c.req.path,
method: c.req.method,
}))
return c.json({ error: 'Internal server error' }, 500)
})
app.notFound((c) => c.json({ error: 'Not found' }, 404))
For domain errors with structured payloads, a discriminated union gives clients something to switch on:
type ApiError =
| { code: 'NOT_FOUND'; resource: string; id: string }
| { code: 'FORBIDDEN'; reason: string }
| { code: 'CONFLICT'; field: string; value: string }
| { code: 'UNPROCESSABLE'; message: string }
function apiError(status: number, error: ApiError): Response {
return new Response(JSON.stringify(error), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
app.get('/users/:id', async (c) => {
const id = c.req.param('id')
const user = await db.users.findById(id)
if (!user) {
return apiError(404, { code: 'NOT_FOUND', resource: 'user', id })
}
return c.json(user)
})
The discriminated union approach lets clients branch on error.code with full type narrowing rather than parsing error message strings. It also forces the server to be explicit about what error conditions exist rather than relying on status codes alone.
RPC Mode: End-to-End Type Safety
This is the feature that separates Hono from every Express alternative. In RPC mode, you export the router type and import it on the client. The client infers all route paths, parameter types, request body types, and response types from the server code. No code generation. No OpenAPI spec maintenance. No schema drift between server and client.
The server side requires that routes be chained rather than declared on separate app.get(...) calls, so the TypeScript compiler can accumulate the type:
// src/routes/users.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { HTTPException } from 'hono/http-exception'
import { z } from 'zod'
const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
role: z.enum(['admin', 'member', 'viewer']),
createdAt: z.string().datetime(),
})
const createUserSchema = userSchema.omit({ id: true, createdAt: true })
const updateUserSchema = createUserSchema.partial()
export const users = new Hono()
.get('/', async (c) => {
const list = await db.users.list()
return c.json({ users: list })
})
.get('/:id', async (c) => {
const id = c.req.param('id')
const user = await db.users.findById(id)
if (!user) throw new HTTPException(404, { message: 'User not found' })
return c.json(user)
})
.post(
'/',
zValidator('json', createUserSchema),
async (c) => {
const data = c.req.valid('json')
const user = await db.users.create(data)
return c.json(user, 201)
}
)
.patch(
'/:id',
zValidator('json', updateUserSchema),
async (c) => {
const id = c.req.param('id')
const data = c.req.valid('json')
const user = await db.users.update(id, data)
if (!user) throw new HTTPException(404, { message: 'User not found' })
return c.json(user)
}
)
.delete('/:id', async (c) => {
const id = c.req.param('id')
await db.users.delete(id)
return c.body(null, 204)
})
export type UsersRouteType = typeof users
// src/index.ts
import { Hono } from 'hono'
import { users } from './routes/users'
const app = new Hono()
.route('/users', users)
export type AppType = typeof app
export default app
On the client side, import the type and instantiate the RPC client:
// client/api.ts
import { hc } from 'hono/client'
import type { AppType } from '../src/index'
const client = hc<AppType>('https://api.yourdomain.com')
// Fully typed: path, method, input shape, and response shape
const response = await client.users.$get()
const { users } = await response.json()
// users is typed as the array of user objects from your Zod schema
const created = await client.users.$post({
json: {
email: 'alice@example.com',
name: 'Alice',
role: 'member',
// role: 'superadmin' would be a compile error here
},
})
const user = await created.json()
// user is typed as the full user object including id and createdAt
const updated = await client.users[':id'].$patch({
param: { id: user.id },
json: { name: 'Alice Smith' },
})
When you add a required field to createUserSchema, every client call site that does not supply it becomes a compile error. When you rename a response field, the compiler surfaces every access that uses the old name. The type contract between server and client is enforced at build time rather than discovered at runtime.
The constraint to understand before adopting RPC mode: server and client must share types, which means they live in the same monorepo or you publish the server types as an npm package. For a monorepo with a frontend and a backend service, this is straightforward. For a public API with external consumers, you need a separate distribution strategy. The RPC client is not a substitute for an OpenAPI spec in that context: it is an alternative for internal consumers who control both sides.
Tradeoffs: Hono vs. Express vs. Fastify
| Dimension | Express | Fastify | Hono |
|---|---|---|---|
| TypeScript ergonomics | Bolted on, manual | Good (JSON Schema) | Native, structural |
| Input validation | Third-party assembly | JSON Schema built-in | Zod via official middleware |
| End-to-end client types | Requires codegen (OpenAPI) | Requires codegen (OpenAPI) | Native RPC, no codegen |
| Runtime targets | Node.js only | Node.js only | Workers, Node, Deno, Bun |
| Middleware model | (req, res, next) | Plugin system | (c, next) with generics |
| Ecosystem maturity | Largest | Large | Smaller, growing |
| Raw throughput (Node.js) | Moderate | High | Moderate |
| Learning curve | Low | Medium | Low |
| When to prefer | Existing codebase, broad ecosystem | High-throughput Node, existing Fastify investment | New project, edge targets, RPC client pattern |
Fastify’s throughput advantage on bare metal Node.js is real: its schema-based response serialization outperforms Hono’s c.json() on pure benchmark workloads. In practice, for most API services the bottleneck is database latency, not framework serialization overhead. Measure before letting benchmarks drive the decision.
Express is the right choice when you are extending an existing Express codebase and the migration cost is not justified. It is not a good starting point for a new project that needs type safety throughout.
Testing Typed Routes
Hono provides app.request() for testing without starting an HTTP server. It creates a real Request object and runs the full middleware chain, including validators and error handlers. This is more reliable than mocking individual handlers:
// src/routes/users.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import app from '../index'
import { db } from '../db'
describe('POST /users', () => {
it('creates a user with valid data', async () => {
const res = await app.request('/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'bob@example.com',
name: 'Bob',
role: 'member',
}),
})
expect(res.status).toBe(201)
const user = await res.json()
expect(user.email).toBe('bob@example.com')
expect(user.id).toMatch(/^[0-9a-f-]{36}$/)
})
it('rejects an invalid role', async () => {
const res = await app.request('/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'carol@example.com',
name: 'Carol',
role: 'superadmin',
}),
})
expect(res.status).toBe(400)
const error = await res.json()
expect(error.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ field: 'role' }),
])
)
})
it('rejects a request with missing fields', async () => {
const res = await app.request('/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'incomplete@example.com' }),
})
expect(res.status).toBe(400)
})
})
describe('GET /users/:id', () => {
it('returns 404 for a nonexistent user', async () => {
const res = await app.request('/users/00000000-0000-0000-0000-000000000000')
expect(res.status).toBe(404)
const error = await res.json()
expect(error.code).toBe('NOT_FOUND')
})
})
For Workers-specific testing where c.env carries D1, KV, and other bindings, use @cloudflare/vitest-pool-workers, which provides a real Workers runtime in the test environment:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
pool: '@cloudflare/vitest-pool-workers',
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.toml' },
},
},
},
})
With this configuration, c.env.DB in your tests resolves to a real D1 instance (local SQLite via Miniflare under the hood). Your tests exercise the same code path that runs in production, including binding types. Unit tests that mock the database catch a different class of bugs than integration tests that run against the real binding interface.
Deployment to Cloudflare Workers
The Workers entry point is just the fetch export. Nothing special is needed beyond what you have already written:
// src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { secureHeaders } from 'hono/secure-headers'
import { HTTPException } from 'hono/http-exception'
import { users } from './routes/users'
type Bindings = {
DB: D1Database
KV: KVNamespace
ENVIRONMENT: 'development' | 'staging' | 'production'
JWT_SECRET: string
}
const app = new Hono<{ Bindings: Bindings }>()
// Global middleware
app.use('*', secureHeaders())
app.use('*', logger())
app.use(
'/api/*',
cors({
origin: (origin) =>
origin.endsWith('.yourdomain.com') ? origin : 'https://yourdomain.com',
allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Authorization', 'Content-Type'],
maxAge: 86400,
})
)
app.route('/api/users', users)
app.onError((err, c) => {
if (err instanceof HTTPException) return err.getResponse()
console.error(err.message, err.stack)
return c.json({ error: 'Internal server error' }, 500)
})
app.notFound((c) => c.json({ error: 'Not found' }, 404))
export default app
# wrangler.toml
name = "my-api"
main = "src/index.ts"
compatibility_date = "2026-01-01"
[[d1_databases]]
binding = "DB"
database_name = "my-db"
database_id = "your-database-id-here"
[[kv_namespaces]]
binding = "KV"
id = "your-kv-namespace-id-here"
[vars]
ENVIRONMENT = "production"
npx wrangler deploy
Two production concerns worth addressing before launch:
Request body size limits. Workers caps request bodies at 128 MB. For file upload routes, validate Content-Length before reading the body. A request over the limit does not fail gracefully by default: it throws mid-stream.
D1 query latency. D1 is HTTP-based. A Worker in Frankfurt hitting a D1 database in the US East region adds 100-200ms of round-trip latency per query. For read-heavy APIs with p99 requirements, either co-locate compute and data using the same Cloudflare region, use KV for read-through caching on stable data, or evaluate Hyperdrive for connection pooling to an external Postgres instance in the same region as the Worker.
Secrets in wrangler.toml. Never put secret values in wrangler.toml. Use npx wrangler secret put JWT_SECRET to store them as encrypted environment variables. The [vars] section in wrangler.toml is for non-sensitive configuration only.
Closing Thoughts
The case for Hono is not performance benchmarks, though the numbers are competitive. It is the cost of maintaining the type contract between your server and your clients.
With Express, that contract is informal. You document it, you generate types from it, you run tests against it, and you hope it stays in sync as the codebase evolves. With Hono’s RPC mode, the contract is the code. Renaming a field in a response schema is a compiler error on every client call site that does not handle the rename. Adding a required field to a request schema is a compile error at every call site that does not supply it.
That is a qualitatively different relationship between server and client code. The bugs you catch with tsc during a refactor do not become incidents at 2 AM. For a small team moving fast, compile-time correctness on API contracts is worth more than the few microseconds of framework overhead you might save with a different choice.
The tradeoffs are real: you need a monorepo or shared type package to get the RPC benefits, and the ecosystem is smaller than Express. Neither is a blocker for a new project. They are architectural decisions you should make explicitly rather than discovering after the fact.
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
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
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
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
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.