Hey, this is Hono's RPC
この記事は英語で書かれた原文をAIが翻訳したものです。
Hey, this is Hono's RPC
WebフレームワークのHonoは、もうひとつの人気JavaScriptフレームワークであるExpressとよく比較されます。どちらも似たようなことができますが、Honoにはアドバンテージがあります。TypeScriptをファーストクラスでサポートしていることです。Honoのユニークな機能のひとつが「RPC」で、TypeScriptの型を使ってサーバーとクライアントの間で仕様を共有できます。これは他のフレームワークにはあまりない機能です。この記事では、HonoのRPC機能を紹介します。
HonoのRPCとは?
まず、HonoのRPCがどういうものかをまとめてみましょう。
- Web APIの仕様、特に入力と出力をサーバーとクライアントの間で共有するために使う
- OpenAPIやgRPCでやりたかったことを実現できる可能性がある
- サーバーとクライアントの両方がTypeScriptで書かれている必要がある
- tRPCのような類似ツールも存在するが、Honoでは普通のREST APIのままで使える
- クライアントは
fetchのラッパーで、標準のResponseオブジェクトを扱う - 「型安全」であり、エディタが強力な補完をしてくれる
デモ
百聞は一見にしかず。まずは見てみましょう。
サーバー
まず、サーバー側でAPIを書きます。ユーザー情報を扱うエンドポイントを作りましょう。string型のnameとnumber型のageを受け取ることをZodで定義します。リクエストのJSONボディをバリデーションするため、jsonを指定してバリデーションミドルウェアにスキーマを渡します。ハンドラの中ではc.req.valid()でバリデーション済みの値を型付きで取得します。c.json()でstring型のmessageを持つレスポンスを返します。
クライアント
次にクライアント側です。サーバーからexportされたAppTypeという型をimportします。この型をhc関数にジェネリクスとして渡してクライアントオブジェクトを作ります。すると、client.api.users.$postのように、APIエンドポイントのパスとメソッドが補完されるようになります。クライアントは、リクエストボディがnameとageフィールドを持つJSONであるべきことも知っています。resは標準のResponseオブジェクトですが、res.json()を呼ぶと型付きのオブジェクトが返ってきて、messageがstringであることが分かります。
RPCを作る
HonoでRPCを作る方法をもう少し詳しく見ていきましょう。
シンプルなREST APIを書く
HonoのRPC機能は「普通の」REST APIで動きます。まずはシンプルなAPIを作りましょう。次のコードは/api/usersへのPOSTリクエストを受け付け、messageフィールドを持つJSONレスポンスを返します。
app.post('/api/users', (c) => {
return c.json({
message: `young man is 20 years old`
})
})
型を作って共有する
次に型を作って共有します。簡単です。app.post()の戻り値を受け取って、typeofで型を取得するだけ。これをAppTypeとしてexportします。
// 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
hcでクライアントを作る
ではクライアントを書きましょう。コマンドラインから実行するスクリプトを想定して、実装は最小限にします。
まず、サーバーからexportされたAppType型をimportします。重要なのは、これが「型」であって実際のコードではないことです。そして、この型をhc関数にジェネリクスとして渡します。これでクライアントオブジェクトができます。
import type { AppType } from './server'
import { hc } from 'hono/client'
const client = hc<AppType>('/')
リクエストを送る
これでエンドポイントのパスとメソッドが補完されるようになります。
const res = await client.api.users.$post()

レスポンスを扱う
resは標準のWeb Responseオブジェクトなので、res.okが使えます。ただし、res.json()を呼んでJSONオブジェクトを取得すると、それには型が付いています。サーバーがstring型のmessageフィールドを返すので、クライアントはdata.messageがstringであることを知っています。
if (res.ok) {
const data = await res.json()
console.log(data.message)
}
Zodでバリデーションする
先ほどの例では、サーバーはただレスポンスを返すだけでした。今度は、クライアントからデータを送り、サーバーがそれをバリデーションしてから処理するようにしましょう。
Honoはいくつかのバリデータをサポートしていますが、今回はZodを使います。スキーマを定義しましょう。
import { z } from 'zod'
// ...
const schema = z.object({
name: z.string(),
age: z.number()
})
どんなデータを受け取りたいかを考えて、それをそのままスキーマに落とし込めばOKです。
ハンドラではc.req.valid()メソッドを使って、バリデーション済みのデータを型付きで取得できます。

この例ではバリデーション済みのデータをテキストメッセージに入れているだけですが、ロジックを含めたり、他のロジックにデータを渡したりすることもできます。
クライアントからデータを送る
サーバーがデータを受け取れるようになったので、クライアントから値を送れます。client.api.users.$post()メソッドに、JSONフォーマットの指定とデータを渡します。
const res = await client.api.users.$post({
'json': {
'name': 'young man',
'age': 20
}
})
型がサーバーで定義したスキーマと一致していることが分かります。たとえばageを'20'のような文字列で送ろうとすると、エディタが赤い波線でエラーを示してくれます。

他のバリデータを使う
先ほどはバリデータにZodを使いましたが、どのバリデータでも使えます。特に以下のバリデータはHonoのミドルウェアがサポートしていて、すぐに使えます。
たとえばValibotを使いたければ、次のように書けます。バリデータとHonoのバリデーションミドルウェアを変えるだけで、他はそのまま、型もちゃんと付きます。
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')
// ...
})
ステータスコードで分岐する
ステータスコードによってJSONレスポンスの型が変わることがあります。c.json()の第二引数でステータスコードを明示的に指定すると、クライアントはステータスコードに応じて自動的に型を選択してくれます。
たとえば、URLパラメータでidを受け取ってuserを検索し、見つからなければerrorプロパティを持つJSONと404を返し、見つかればuserと200を返すとしましょう。
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
)
})
クライアントのコードはこうです。resのステータスで分岐します。res.okがtrueのときはJSONの中身が{user:User}に、res.status === 404のときは{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}`)
}
分岐によってres.json()の型が変わります。

ユースケース
ここまでは最小限のクライアント実装だけを扱ってきましたが、HonoのRPC機能にはいくつかのユースケースがあります。
フロントエンド
フロントエンドと組み合わせるとこうなります。HonoのJSXはReactのフックの一部と互換性があるので、honoパッケージだけでこれを書けます。
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)
これはどこに置いてもいいのですが、たとえば、RPC対応のAPIとWebページの両方を配信する単一のHonoサーバーアプリを作ることもできます。
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
フルスタックフレームワークの中で
面白いのは、HonoのRPCがNext.jsやSvelteKitのようなフルスタックフレームワークの中でも使えることです。APIルートをHonoサーバーで書き、型を共有して、hcで作ったクライアントをUI部分で使えます。
テストで使う
HonoにはTesting Helpersがあります。これを使うと、hcクライアントと同じような型安全で実データを使ったやり取りができます。つまり、返り値のresをチェックすることで、サーバーアプリが正しく動作しているかをテストできます。Webのオブジェクトは抽象化されているので、ポートを開いたり実際のサーバーを立てたりせずにテストできます。
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
HonoのRPC機能を、Viteベースの新しいメタフレームワーク「HonoX」と組み合わせて使うと、さらに強力になります。
https://github.com/honojs/honox
これについては今後の記事で詳しく紹介します。
Zod OpenAPI
それでもOpenAPIドキュメントを生成したい場合はどうすればいいのでしょうか?HonoにはZod OpenAPIというラッパーがあります。これを使えば、型安全の恩恵を受けながらOpenAPIドキュメントを生成できます。
https://github.com/honojs/middleware/tree/main/packages/zod-openapi
まとめ
Honoの目玉機能のひとつ「RPC」を見てきました。まとめると、HonoでのRPC体験はこうなります。
- HonoでREST APIを書く
- 型を共有する
hcに渡してクライアントを作る- エンドポイントとリクエストボディが補完される
- レスポンスのJSONの中身に型が付く
- ステータスコードで分岐できる
「サーバーとクライアントの仕様共有」という問題を、TypeScriptの型を使って「カジュアルに」解決できるのがHonoの注目すべき点です。ハマるシーンがあれば、ぜひ試してみてください。