見よ、これがHonoのRPCだ
僕が開発しているWebフレームワークHonoは、同じJavaScriptのフレームワーク、Expressと比べられることが多いです。どちらもやれることはほぼ同じですが、HonoのアドバンテージはファーストクラスでTypeScriptをサポートしていることです。特に「RPC」機能は他のフレームワークにはなかった「TypeScriptの型でサーバーとクライアントの仕様を共有する」ことを可能にしています。今回はその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という関数にジェネリクスとして渡し、クライアントオブジェクトを作成します。すると、APIのエンドポイントのパスとメソッド情報がclient.api.users.$postのように補完されます。しかも、クライアントが送るべきボディがJSONであり、中身がnameとageであることも型として表現されています。resは標準のResponseオブジェクトですが、res.json()でJSONオブジェクトを取り出すとそれにも型がついていてstringのmessageを持つことが分かります。
RPCを作る
より詳しくRPCの作り方を見ていきましょう。
普通のREST APIを書く
HonoのRPCは「普通の」REST APIを書けばそれだけでRPCに対応します。まず超簡単な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しておきます。
// routesを定義する
const routes = app.post('/api/users', (c) => {
return c.json({
message: `young man is 20 years old`
})
})
// routesの型を取り、exportしておく
export type AppType = typeof routes
hcでクライアントを作る
クライアントを書きます。今回はCLIから叩かれるスクリプトを想定し、最低限の実装にします。
まずサーバーから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()で取得した値には型が付きます。サーバーからはstring型のmessageというフィールドが返却されているので、クライアントでもdata.messageがstringになります。
if (res.ok) {
const data = await res.json()
console.log(data.message)
}
Zodでバリデートする
上記はサーバーがただレスポンスを返すだけでした。次は、クライアントがデータを送り、それをサーバーが検証(Validation)して中身を扱うようにしてみます。
いくつかのValidatorに対応していますが、今回はZodを使います。スキーマを定義しましょう。
import { z } from 'zod'
// ...
const schema = z.object({
name: z.string(),
age: z.number()
})
「どんなデータを受け取りたいか?」を考え、それをそのままスキーマに落とし込めばいいでしょう。
ハンドラの中では、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()の第2引数にステータスコードを明示的に指定すると、クライアントではステータスコードに基づいて型が自動的に選択されます。
例えば、URLパラメータでidを受け取ってuserを検索、なければ404でerrorというプロパティが入ったJSON、あれば200でuserを返却します。
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の時は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の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)
これはどこに置いてもいいのですが、例えば、ひとつのHonoのサーバーアプリでRPCに対応させつつ、WebページとAPIを配信するということができます。
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
フルスタックフレームワークの中で
興味深いのは、Next.jsやSveleKitなどフルスタックフレームワークの中で使われています。APIルートをHonoのサーバーで書いて、型を共有。UIの部分でhcで作ったクライアントを使う。例えば、Next.js内での例はこちらです。
テストでの活用
HonoにはTestingヘルパーがあります。これを使うと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とViteのメタフレームワーク「HonoX」の中で使うとより効果的です。
これについてはまた別の機会で紹介します。
Zod OpenAPI
それでも、OpenAPIのドキュメントを吐きたい!という場合は、Zod OpenAPIというHonoのラッパーがあります。これを使うとこれまで見てきた、型安全のメリットを享受しつつ、OpenAPIのドキュメントを生やせます。
まとめ
以上、Honoの推し機能のひとつ「RPC」について紹介しました。おさらいすると以下でRPCを体験できます。
- HonoでREST APIを書く
- 型を共有する
-
hcに渡してクライアントを作る - エンドポイントとリクエストボディが補完される
- レスポンスのJSONの中身に型が付いている
- ステータスコードで分岐できる
「サーバーとクライアントの仕様の共有」という永遠のテーマをTypeScriptの型を使って「カジュアル」に解決しているのが特筆すべき点です。使える場面がある型はぜひ使ってみてください。
Discussion
素敵🥰
(今絶賛使ってみてます!)