All posts

End-to-end types with Hono RPC

Getting a typed client from a Workers API without generating anything, and where the approach runs out.

Published
  • Hono
  • Cloudflare
  • TypeScript
Contents

Hono’s RPC mode gives you a typed client for your API without a codegen step. The types flow from the route definitions straight into the client, so renaming a field breaks the build at every call site.

The setup

Export the app type from the server:

const routes = app
  .get('/events', (c) => c.json({ events: [] }))
  .post('/events', zValidator('json', createEventSchema), (c) => {
    const body = c.req.valid('json') // typed from the schema
    return c.json({ id: '1' }, 201)
  })

export type AppType = typeof routes

Then consume it:

import { hc } from 'hono/client'
const client = hc<AppType>('https://api.example.com')

const res = await client.events.$post({ json: { name: 'Walk' } })

The json argument is typed from the Zod schema on the server. No generated file, no build step, no drift.

The part that matters

Chaining is not stylistic. typeof routes only carries the routes present on the value it is applied to, so this:

app.get('/a', handler)
app.get('/b', handler)
export type AppType = typeof app // only knows about the base app

silently produces a client missing both routes. It type-checks. It fails at runtime. Chain the definitions or the whole approach quietly stops working.

Where it runs out

Compile time scales with route count. Past a few dozen routes the inferred type gets large enough that editor responsiveness suffers. Splitting into sub-apps and composing helps, but it is a real ceiling.

It assumes a monorepo. The client needs typeof routes from the server’s source. If the consumer is a separate repo, you are back to publishing types or generating a spec — at which point OpenAPI is the better tool.

For a monorepo with one API and one or two clients, though, deleting the codegen step is worth a lot. The schema is the contract, and there is exactly one copy of it.