Type-safe clients and testing

Build a typed RPC client

Infer a client from route definitions and understand the repository and compilation boundaries that make it work.

Hono RPC shares TypeScript types between server and client. It is compile-time convenience, not runtime validation.

Export the app type from the server module:

// src/app.js
import { Hono } from 'hono'

const app = new Hono()
  .get('/bookmarks', (c) => c.json([]))
  .post('/bookmarks', async (c) => {
    const body = await c.req.json()
    return c.json({ id: 'bk_01', ...body }, 201)
  })

export type AppType = typeof app
export { app }

The client in the same workspace imports that type and gets typed paths and payloads:

import { hc } from 'hono/client'
import type { AppType } from '../src/app.js'

const client = hc<AppType>('http://localhost:3000')

const res = await client.bookmarks.$post({
  json: { title: 'Hono docs', url: 'https://hono.dev' }
})
const bookmark = await res.json()

When the server is running, that call hits the same endpoint as curl:

curl -s -X POST http://localhost:3000/bookmarks \
  -H 'content-type: application/json' \
  -d '{"title":"Hono docs","url":"https://hono.dev"}'

You should see status 201 and JSON like {"id":"bk_01","title":"Hono docs","url":"https://hono.dev"}. The typed client returns the same body; TypeScript just knows the field names ahead of time.

A common failure: you rename the route to /api/bookmarks on the server but leave the client base URL at http://localhost:3000. The client still calls /bookmarks, curl gets 404 {"error":"Route not found"}, and tsc stays green because you never updated AppType usage. Fix the route on one side or mount the sub-app at the path the client expects.

Keep runtime dependencies out of the browser bundle. Export type AppType and use import type on the client. Do not ship server-only modules to the frontend just to get inference working.

Path chaining like client.bookmarks[':id'].$get({ param: { id: 'bk_01' } }) follows your route tree. External consumers still need OpenAPI or hand-written fetch; RPC is a dev-time accelerator inside the repo.

Server-side validation stays mandatory. The typed client is for developers who share your repo, not for every HTTP caller on the internet.

List bookmarks through the typed client and confirm the array shape matches curl:

curl -s http://localhost:3000/bookmarks

Both should return [] or the same JSON array. If the client types promise fields the server no longer sends, update the route return type and re-export AppType.

Try this on your own project: add AppType, wire hc, and confirm tsc fails when you rename a route without updating the client call.

Lesson completed