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:
- It’s used to share Web API specifications, especially input and output, between the server and client.
- It could fulfill what you wanted to do with OpenAPI or gRPC.
- Both the server and client must be written in TypeScript.
- Similar tools like tRPC exist, but with Hono, you can use it with just a regular REST API.
- The client is a wrapper around
fetch
and handles standardResponse
objects. - It offers “type safety,” meaning your editor will give you strong autocomplete support.
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()
.
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
.
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()
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.
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.
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.
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:
- Writing REST APIs with Hono
- Sharing types
- Passing them to
hc
to create a client - Getting autocomplete for endpoints and request bodies
- Having typed JSON content in the response
- Branching by status code
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.