Blog - Yusuke Wada

Screenshot

Hey, this is Hono's RPC


Hey, this is Hono's RPC

The web framework Hono is often compared to another popular JavaScript framework, Express. Both can do similar things, but Hono has an advantage: it supports TypeScript as a first-class. One of Hono’s unique features is its “RPC” capability, which allows the server and client to share specifications using TypeScript types. This is something other frameworks don’t typically offer. In this post, I’ll introduce you to Hono’s RPC feature.

What is Hono’s RPC?

First, let’s summarize what Hono’s RPC is all about:

Demo

Seeing is believing, so let’s take a look.

Server

First, we write the API on the server side. We’ll create an endpoint to handle user information. We define that we want to receive a name of type string and an age of type number using Zod. To validate the JSON body of the request, we pass the schema to the validation middleware using json. In the handler, we use c.req.valid() to get the validated values with types. We return a response with a message of type string using c.json().

Server

Client

Next, the client side. We import a type called AppType that was exported from the server. We pass this type as a generic to the hc function to create a client object. This enables autocomplete for the API endpoint paths and methods, like client.api.users.$post. The client also knows that the request body should be JSON with a name and age field. Even though res is a standard Response object, when you call res.json(), it will return a typed object, letting you know that message is a string.

Client

Creating an RPC

Let’s dive deeper into how to create an RPC with Hono.

Writing a Simple REST API

Hono’s RPC feature works with “normal” REST APIs. Let’s create a simple API first. The following code accepts a POST request at /api/users and returns a JSON response with a message field.

app.post('/api/users', (c) => {
  return c.json({
    message: `young man is 20 years old`
  })
})

Creating and Sharing Types

Next, we’ll create a type and share it. It’s simple. Just grab the return value of app.post() and use typeof to get the type. We’ll export this as AppType.

// Define routes
const routes = app.post('/api/users', (c) => {
  return c.json({
    message: `young man is 20 years old`
  })
})

// Get the type of the routes and export it
export type AppType = typeof routes

Creating a Client with hc

Now let’s write the client. We’ll keep the implementation minimal, assuming it’s a script run from the command line.

First, import the AppType type that was exported from the server. The important thing is that this is a “type” and not actual code. Then, pass this type as a generic to the hc function. This creates the client object.

import type { AppType } from './server'
import { hc } from 'hono/client'

const client = hc<AppType>('/')

Making a Request

Now you get autocomplete for the endpoint paths and methods.

const res = await client.api.users.$post()

path

Handling the Response

res is a standard Web Response object, so you can use res.ok. However, when you call res.json() to get the JSON object, it will be typed. Since the server returns a message field of type string, the client knows that data.message is a string.

if (res.ok) {
  const data = await res.json()
  console.log(data.message)
}

Validating with Zod

The previous example had the server just returning a response. Now, let’s have the client send data that the server will validate before handling.

Hono supports several validators, but we’ll use Zod for this example. Let’s define the schema.

import { z } from 'zod'

// ...

const schema = z.object({
  name: z.string(),
  age: z.number()
})

You can simply think about what kind of data you want to receive and translate that directly into the schema.

In the handler, you can use the c.req.valid() method to get the validated data with types.

schema

In this case, we’re just put the validated data into a text message, but you can also include logic or pass the data to other logic.

Sending Data from the Client

Once the server is ready to receive data, the client can send values. You pass the JSON format and the data to the client.api.users.$post() method.

const res = await client.api.users.$post({
  'json': {
    'name': 'young man',
    'age': 20
  }
})

You can see that the type matches the schema defined on the server. For example, if you try to send age as a string like '20', your editor will show a red squiggly line indicating an error.

request body

Using Other Validators

We used Zod as the validator earlier, but you can use any validator. In particular, the following validators are supported by Hono’s middleware and can be used immediately:

For example, if you want to use Valibot, you can write it like this. Just change the validator and Hono’s validation middleware, and everything else stays the same, with types still being applied.

import { number, object, string } from 'valibot'
import { vValidator } from '@hono/valibot-validator'

// ...

const schema = object({
  name: string(),
  age: number()
})

const routes = app.post('/api/users', vValidator('json', schema), (c) => {
  const data = c.req.valid('json')
  // ...
})

Branching by Status Code

Sometimes the JSON response type changes depending on the status code. If you explicitly specify the status code in the second argument of c.json(), the client will automatically select the type based on the status code.

For example, let’s say you receive an id in the URL parameter, search for a user, and return 404 with an error property in the JSON if not found, or return 200 with the user if found.

const schema = z.object({
  id: z.string()
})

const routes = app.get('/api/users/:id', zValidator('param', schema), (c) => {
  const { id } = c.req.valid('param')

  const user = findUser(id)

  if (!user) {
    return c.json(
      {
        error: 'not found'
      },
      404
    )
  }

  return c.json(
    {
      user
    },
    200
  )
})

Here is the client code. You’ll branch based on the status of res. When res.ok is true, the JSON content will be {user:User}, and when res.status === 404, it will be {error:string}.

const res = await client.api.users[':id'].$get({
  param: {
    id: '123'
  }
})

if (res.ok) {
  const data200 = await res.json()
  console.log(`Get User: ${data200.user.name}`)
}

if (res.status === 404) {
  const data404 = await res.json()
  console.log(`Error: ${data404.error}`)
}

The type of res.json() changes depending on the branch.

status code

Use Cases

So far, we’ve only covered minimal client implementations, but there are several use cases for Hono’s RPC feature.

Frontend

Here’s how it looks when combined with a frontend. Hono’s JSX is compatible with some of React’s hooks, so you can write this using only the hono package.

import { render } from 'hono/jsx/dom'
import { useEffect, useState } from 'hono/jsx'
import { hc } from 'hono/client'
import { AppType } from '.'

function App() {
  const [message, setMessage] = useState('')

  const client = hc<AppType>('/')

  const fetchApi = async () => {
    const res = await client.api.users.$post({
      json: {
        name: 'young man',
        age: 20
      }
    })
    const data = await res.json()
    setMessage(data.message)
  }

  useEffect(() => {
    fetchApi()
  }, [])

  return <p>{message}</p>
}

const domNode = document.getElementById('root')!
render(<App />, domNode)

You can place this anywhere, but for example, you can make a single Hono server app that serves both the RPC-enabled API and the web page.

import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'

const app = new Hono()

app.get('/', (c) => {
  return c.html(
    <html>
      <head>
        <script type="module" src="/src/client.tsx"></script>
      </head>
      <body>
        <div id="root"></div>
      </body>
    </html>
  )
})

const schema = z.object({
  name: z.string(),
  age: z.number()
})

const routes = app.post('/api/users', zValidator('json', schema), (c) => {
  const data = c.req.valid('json')
  return c.json({
    message: `${data.name} is ${data.age.toString()} years old`
  })
})

export type AppType = typeof routes

export default app

In Full-Stack Frameworks

What’s interesting is that Hono’s RPC can be used within full-stack frameworks like Next.js or SvelteKit. You can write the API route with a Hono server, share the types, and use the client created with hc in the UI part.

Using it in Tests

Hono has Testing Helpers. By using this, you can do the same kind of type-safe, real-data interaction as with the hc client. So, by checking the returned value res, you can test if the server app is behaving correctly. Since the web objects are abstracted, you can test without opening ports or starting a real server.

import { testClient } from 'hono/testing'
import app from './server'

it('Should return 200 response', async () => {
  const client = testClient(app)
  const res = await client.api.users[':id'].$get({
    param: {
      id: '123'
    }
  })
  expect(res.status).toBe(200)
  expect(await res.json()).toEqual({ message: 'my id is 123' })
})

HonoX

Using Hono’s RPC feature in combination with the upcoming meta-framework “HonoX,” built with Vite, will be even more powerful.

https://github.com/honojs/honox

I’ll introduce more about this in a future post.

Zod OpenAPI

But what if you still want to generate OpenAPI documentation? There’s a wrapper for Hono called Zod OpenAPI. With it, you can enjoy the benefits of type safety while generating OpenAPI documentation.

https://github.com/honojs/middleware/tree/main/packages/zod-openapi

Conclusion

That was a look at one of Hono’s standout features, “RPC.” To summarize, you can experience RPC with Hono by:

Hono’s ability to “casually” solve the “sharing server and client specifications” problem using TypeScript types is something to note. If you have scenarios where this fits, I encourage you to try it out.

https://hono.dev/guides/rpc