Encodera

Thanks for reading!

Donate to the site — we work hard for you.

Contents

The hc RPC client

When both the frontend and the server are written in TypeScript, it helps if the client knows the API addresses and response shapes. Hono provides the hc client from hono/client for this. It takes the server app's type and turns routes into object methods: instead of fetch('/posts') you write client.posts.$get(), and TypeScript checks the parameters and the response type. This approach is called RPC — remote procedure call.

Routes as a chain

The type is built from a chain of calls, so the routes must be written as a single expression. Rewrite the beginning of src/routes/posts.ts:

// src/routes/posts.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { db, type Post } from '../db.js'
import { postSchema } from '../schemas.js'
 
const posts = new Hono()
  .get('/', (c) => {
    const rows = db.prepare('SELECT * FROM posts ORDER BY id DESC').all() as Post[]
    return c.json(rows)
  })
  .get('/:id', (c) => {
    const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(c.req.param('id')) as Post | undefined
    return post ? c.json(post, 200) : c.json({ error: 'Post not found' }, 404)
  })
  .post('/', zValidator('json', postSchema), (c) => {
    const { title, body } = c.req.valid('json')
    const result = db.prepare('INSERT INTO posts (title, body) VALUES (?, ?)').run(title, body)
    return c.json({ id: Number(result.lastInsertRowid) }, 201)
  })
 
export default posts

Each method returns the app with the route already added, and the type remembers every route in the chain.

The app type

In src/index.ts keep the result of app.route() and export its type:

// src/index.ts
const routes = app.route('/posts', posts)
 
export type AppType = typeof routes

AppType is only a type: it does not end up in the built JavaScript, and the client does not receive any server code.

The client

The client usually lives in a frontend project that imports the type from the server project. To try it, create a script src/client.ts in the same project:

// src/client.ts
import { hc } from 'hono/client'
import type { AppType } from './index.js'
 
const client = hc<AppType>('http://localhost:3000/')
 
const listRes = await client.posts.$get()
const list = await listRes.json()
console.log(list.map((post) => post.title))
 
const created = await client.posts.$post({
  json: { title: 'From the RPC client', body: 'Post text' },
})
console.log(created.status) // 201
 
const one = await client.posts[':id'].$get({ param: { id: '1' } })
if (one.ok) {
  const post = await one.json()
  console.log(post.title)
}

Start the server, and in a second terminal run npx tsx src/client.ts. The script prints the post titles, the code 201 and the title of the first post. If you mistype an address or forget the body field, TypeScript underlines the error before anything runs.

Method names

RequestClient call
GET /postsclient.posts.$get()
POST /postsclient.posts.$post({ json })
GET /posts/:idclient.posts[':id'].$get({ param })
GET /search?q=client.search.$get({ query })

Common mistake

Adding routes as separate posts.get(...) statements and then wondering why client.posts has no methods. The type of the posts variable is fixed when it is created and does not change with later calls. For RPC, write routes as a chain and export the type of the chain's result.

Thanks for reading!

Donate to the site — we work hard for you.