TypeScript with Hono RPC: Type-Safe APIs Without Code Generation
AI generated
type
TypeScript · Backend · Edge
TypeScript with Hono RPC
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.

9 min read Hono RPC Edge runtimes

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(baseUrl), 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() with no separate code generation step

Validation

zValidator with Zod automatically flows into the route type

Runtime

Web Standard APIs run natively on Cloudflare Workers and more

11. FAQ: TypeScript with Hono RPC

1What is the difference between Hono RPC and classic code generation?
Classic code generation reads a separate schema like OpenAPI and produces client code from it in its own build step. Hono RPC skips this step entirely and derives the client type directly through TypeScript type inference from the server app's exported AppType.
2What exactly needs to be exported from server to client?
Only the TypeScript type export type AppType = typeof app. The actual server implementation, meaning the handlers' JavaScript code, is not shipped along, only the type information.
3Why do routes need to be chained instead of registered individually?
Every method such as get or post returns a newly typed app instance that knows all routes registered so far. If routes are registered with separate statements, that type accumulation is lost and the final AppType does not contain every endpoint.
4How does request validation work together with Hono RPC?
With the @hono/zod-validator middleware, a Zod schema gets bound to a route. The validated, typed value is available in the handler via c.req.valid() and automatically flows into the route type and thus into the generated client.
5Can an API built with Hono RPC also be used without the generated client?
Yes, every route stays a normal HTTP endpoint with a path and a method that can also be called by external systems, via curl, or from a frontend written in another language, entirely as usual.
6How does Hono RPC fundamentally differ from tRPC?
tRPC uses its own procedure abstraction of queries and mutations instead of classic HTTP routes. Hono RPC stays closer to REST, every endpoint remains a regular HTTP call with a path and a method.
7Does Hono run on Cloudflare Workers without changes?
Yes, since Hono relies exclusively on Web Standard APIs like Request and Response and exports an object with a fetch method that matches the Worker signature exactly, no additional adapter is necessary.
8How do you get typed access to Cloudflare bindings like KV or D1?
Through the generic Env type parameter when creating the app instance, for example new Hono<{ Bindings: Bindings }>(). Handlers then get typed access to c.env without having to fall back to any.
9Does the hc client automatically throw an exception on an error response?
No, the client returns a Response like object with an ok flag. The caller has to check this flag explicitly before accessing response data.
10When does Hono RPC get slower in large applications?
With a very large number of chained routes, type inference can become noticeably slower because the compiler has to re resolve the entire accumulated route type every time the client is used. Splitting the app into several sub routers with their own type helps against that.