Larger applicationsRPC · Hono

Keep the RPC types when the API is split into one Hono per resource: chain each sub-app's routes, chain the route() calls that mount them, export the type of the chain's result, and compute the client's type once for every caller.

What you will learn

Read the theory for Larger applications

All RPC lessons

All Hono courses

loading types…

What you'll learn

  • Chain a sub-app's routes so it contributes them to the type of the app that mounts it
  • Chain route() calls and export typeof the chain's result, with basePath() in the type too
  • Compute ReturnType<typeof hc<AppType>> once in a client module, the guide's compile-before-use pattern

Larger applications

The cats API of Basics lesson 8 was split into one Hono per resource and mounted with app.route(). That split and the RPC types pull in opposite directions: a sub-app registered with app.route('/cats', cats); as a statement is a set of routes the client never sees, and a sub-app whose own routes are statements has an empty type to contribute. The docs' RPC guide and its best-practices page both end on this, "if you want to use RPC features, chain", and this lesson does it for a two-resource API under /api.

Chained sub-apps

A sub-app is chained like any app, and exports itself:

// authors.ts
const app = new Hono()
  .get('/', (c) => c.json(authors, 200))
  .post('/', (c) => c.json(created, 201))
  .get('/:id', (c) => c.json(author, 200));

export default app;

Its paths are relative to wherever it is mounted, '/' for the collection, '/:id' for one item. The mounting app chains its route() calls, and the type it exports is the result of the chain:

// app.ts
const app = new Hono().basePath('/api');

const routes = app.route('/authors', authors).route('/books', books);

export default app;
export type AppType = typeof routes;

route() returns the same app with the sub-app's routes merged into its type under the mount path, so typeof routes knows /api/authors, /api/authors/:id and /api/books, and the client reads client.api.authors[':id'].$get(…). basePath('/api') is in the type too, which is why the client's first segment is api. Whether the runtime export is app or routes does not matter, they are the same object; what matters is that the type comes from the end of the chain. typeof app would be the app before the sub-apps were mounted: a Hono with a base path and no routes, from which hc builds unknown.

Compile the client type once

hc<AppType>() asks TypeScript to walk every route in the app, and a file that calls it in ten places asks ten times. The guide's known issues list the symptom, an editor that slows down as the app grows, and the fix, "compile before use": compute the client's type in one module and export a function that has it.

// client.ts
export type Client = ReturnType<typeof hc<AppType>>;

export const hcWithType = (...args: Parameters<typeof hc>): Client => hc<AppType>(...args);

Callers write hcWithType('https://api.example') and get the same client, typed once. The guide's other suggestions are the same idea from different sides: keep the app and the client in separate files so the client's file is small, and, in a monorepo, keep one version of Hono on both sides, because a mismatch shows up as Type instantiation is excessively deep and possibly infinite.

Your task

data.ts holds two owners and three cats. owners.ts is chained and given; cats.ts is the same code as Basics lesson 8 with a POST added, registered as statements; app.ts mounts both under /api as statements and exports typeof app. client.ts is the compile-once wrapper, and the spec builds its client from it. Nothing compiles yet.

  1. In app.ts, chain the two route() calls and export the type of the result.
  2. Run: the error moves. client.api has owners and no cats.
  3. Chain the routes of cats.ts.
  4. Run: five tests across both resources, one client.

When it fails

  • 'client' is of type 'unknown': AppType is still typeof app, the app before anything was mounted.
  • Property 'cats' does not exist on type '{ owners: … }': cats.ts still registers its routes as statements, so it contributes nothing to the type; or its route() call is missing from the chain.
  • Property 'api' does not exist: basePath('/api') was dropped; the client's first segment is the base path.
  • registers a cat fails with expected 400: the POST in cats.ts no longer refuses an unknown owner, or answers the refusal without its status.

Remember

  • Chain each sub-app's routes and export it; chain the route() calls and export typeof the chain's result.
  • basePath() and mount paths are in the type: client.api.cats[':id'].
  • Compute ReturnType<typeof hc<AppType>> once and export a wrapper; keep app and client in separate files.
  • One Hono version on both sides, or the types collapse.
Stuck? Show a hint

In app.ts: const routes = app.route('/cats', cats).route('/owners', owners); export type AppType = typeof routes. In cats.ts, write the three routes as one chain on new Hono(), as owners.ts does, and keep export default app.