Type-safe APIs without code generation
Hono RPC produces a fully typed API client directly from the return type of the server routes, with no separate code generation step and no duplicated schema. The server itself becomes the single source of truth for endpoint, payload and response.
Table of Contents
- 1. Hono as a lean, runtime-agnostic web framework
- 2. AppType: the contract between server and client
- 3. The hc client: type inference instead of code generation
- 4. Request validation with Zod middleware
- 5. Chaining routes instead of registering them separately
- 6. Hono RPC compared to tRPC
- 7. Deploying to Cloudflare Workers without adaptation
- 8. Handling typed error responses on the client
- 9. Limitations: monorepo coupling and type performance
- 10. Summary
- 11. FAQ
1. Hono as a lean, runtime-agnostic web framework
Hono is a minimalist web framework that deliberately builds on Web Standard APIs like Request and Response instead of inventing its own Node.js specific abstraction. As a result, the same codebase runs unchanged on Cloudflare Workers, Deno, Bun, Node.js and other runtimes, with no adapter layer inside the application logic.
Route definitions follow a chained, Express like style, but are built from the ground up with TypeScript generics. Every method such as get or post returns a new, extended app instance whose type knows all routes registered so far, which later becomes the foundation for the type-safe client.
// src/index.ts
import { Hono } from 'hono';
const app = new Hono()
.get('/posts/:id', (c) => {
const id = c.req.param('id');
return c.json({ id, title: 'Sample post' });
});
export default app;
export type AppType = typeof app;
2. AppType: the contract between server and client
The key building block of Hono RPC is the line export type AppType = typeof app. Instead of maintaining a separate OpenAPI document or GraphQL schema file, the TypeScript type of the app instance itself becomes the contract. Every route, every parameter and every return type from c.json() is already contained within it.
For this type to be available on the client, it only needs to be imported, typically through a shared package in a monorepo or via a relative import in full stack projects with a shared TypeScript setup. The actual JavaScript code of the server implementation is not shipped along with it, only the type, so server logic and client bundle stay cleanly separated.
3. The hc client: type inference instead of code generation
With the function hc, Hono produces a client whose entire structure, nested path segments, HTTP methods and parameters, is derived from the imported AppType. A call like client.posts[':id'].$get({ param: { id: '42' } }) gets checked by the compiler exactly against the server route, including the expected parameter structure.
The return type of the request matches exactly what the server route returned via c.json(). If a field name changes on the server or a field gets removed, the client code immediately reports a compile error at every place using that field, with no code generation step ever sitting in between.
// client.ts
import { hc } from 'hono/client';
import type { AppType } from './index';
const client = hc<AppType>('https://api.mironsoft.de');
async function loadPost(id: string) {
const res = await client.posts[':id'].$get({ param: { id } });
if (!res.ok) {
throw new Error(`Error ${res.status}`);
}
const post = await res.json();
return post; // { id: string; title: string }
}
4. Request validation with Zod middleware
For requests with a body, query parameters or headers, Hono is typically combined with @hono/zod-validator. The middleware validates incoming data at runtime against a Zod schema and exposes the validated, typed result to the handler via c.req.valid('json'), instead of accessing raw, unvalidated values.
The key effect for Hono RPC is that the type of the validated value automatically flows into the route type. As a result, the generated client knows not only the URL structure but also the exact, Zod schema derived shape of the expected request body, so a faulty call surfaces already while writing the client code.
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const CreatePostSchema = z.object({
title: z.string().min(3),
content: z.string(),
});
const app = new Hono().post(
'/posts',
zValidator('json', CreatePostSchema),
async (c) => {
const data = c.req.valid('json');
const post = await db.post.create({ data });
return c.json(post, 201);
},
);
export type AppType = typeof app;
5. Chaining routes instead of registering them separately
For correct type inference, it matters that routes are actually chained instead of registered with separate calls like app.get(...); app.post(...);. Only the chained form accumulates all route types into the same app instance, since every method returns a newly typed instance that extends the previous one.
In larger applications, sub routes are mounted via app.route('/posts', postsRouter), where the sub router's type also needs to be exported and merged into the parent route so that the final AppType truly knows every endpoint.
// routes/posts.ts
import { Hono } from 'hono';
export const postsRouter = new Hono()
.get('/', async (c) => c.json(await db.post.findMany()))
.post('/', async (c) => c.json({ id: 'new-id' }, 201));
// index.ts
import { Hono } from 'hono';
import { postsRouter } from './routes/posts';
const app = new Hono().route('/posts', postsRouter);
export type AppType = typeof app;
6. Hono RPC compared to tRPC
tRPC pursues a similar goal, type-safe APIs without code generation, but works with its own procedure abstraction of queries and mutations rather than regular HTTP routes. Hono RPC stays closer to classic REST, every route is a normal HTTP endpoint with a path and a method that remains fully reachable even without the generated client, for instance from external systems or via curl.
This difference makes Hono RPC especially attractive when the same API needs to be consumed both by an in house type-safe frontend and by third party systems, while tRPC tends to suit self contained full stack applications where no foreign client ever needs to talk to the API.
7. Deploying to Cloudflare Workers without adaptation
Because Hono relies exclusively on Web Standard APIs, the same app definition can be deployed as a Cloudflare Worker without any changes. The export happens through an object with a fetch method, which matches the Worker signature exactly, so no additional adapter is needed between Hono and the Workers runtime.
For typed access to Cloudflare specific bindings such as KV namespaces or D1 databases, Hono supports a generic Env type parameter specified when creating the app instance. Handlers then get typed access to c.env.MY_KV without having to fall back to any.
type Bindings = {
MY_KV: KVNamespace;
DB: D1Database;
};
const app = new Hono<{ Bindings: Bindings }>().get('/cached/:key', async (c) => {
const value = await c.env.MY_KV.get(c.req.param('key'));
return c.json({ value });
});
export default app;
8. Handling typed error responses on the client
The client produced by hc() does not throw an exception by default on an HTTP error response, but instead returns a Response like object with an ok flag. The caller has to check this flag explicitly before accessing response data, which TypeScript supports through type narrowing on the success path.
If a route defines multiple possible status codes with different return types via c.json(data, 404), the client type knows those variants as well and allows distinguishing between differently shaped response bodies based on the status code, instead of assuming just a single, generic error type.
const res = await client.posts[':id'].$get({ param: { id } });
if (res.status === 404) {
const notFound = await res.json(); // { error: string }
console.warn(notFound.error);
return null;
}
if (!res.ok) {
throw new Error(`Unexpected status ${res.status}`);
}
return res.json();
9. Limitations: monorepo coupling and type performance
Hono RPC works most smoothly when server and client share the same TypeScript compiler context, typically within a monorepo using tools like Turborepo or Nx. For fully separate repositories, AppType has to be distributed via a published package, which adds extra build and versioning steps.
In very large applications with hundreds of chained routes, type inference can become noticeably slower, since the compiler has to re resolve the entire accumulated route type every time the client is used. In such cases, splitting the app into several smaller sub routers, each with its own independently exported type, helps.
| Aspect | Hono RPC | tRPC | REST with manually maintained types |
|---|---|---|---|
| Code generation | None, pure type inference | None, pure type inference | Usually yes, e.g. via an OpenAPI generator |
| API style | Classic REST over HTTP | Its own procedure abstraction | Classic REST |
| Access without a client | Fully possible | Harder, tied to procedures | Fully possible |
| Edge readiness | Native, Web Standard APIs | Depends on the adapter | Depends on the framework |
| Server/client coupling | Requires a shared TypeScript context | Requires a shared TypeScript context | Decoupled via a schema file |
Mironsoft
TypeScript migration, type safety, and team onboarding
A JavaScript codebase without type safety, but no time for a full migration?
We migrate existing JavaScript projects to TypeScript step by step, set up strict compiler settings cleanly, and bring teams to the same type-safety level with code reviews and style guides.
Migration Roadmap
Plan and execute a gradual JS-to-TS migration without big-bang risk.
Strict Mode Rollout
Set up tsconfig.json, ESLint rules, and CI checks for lasting type safety.
Team Onboarding
Bring developers up to speed on TypeScript best practices with workshops and reviews.
10. Summary
TypeScript with Hono RPC
Contract
AppType derives the client type directly from the server route
Client creation
hc
Validation
zValidator with Zod automatically flows into the route type
Runtime
Web Standard APIs run natively on Cloudflare Workers and more