Hono RPC + Zod:コード生成なしでサーバーからクライアントまで型安全に
- 課題: ネットワーク境界で型が切れる——フロントがAPIを呼び、リクエスト/レスポンスの形を手で同期する必要があり、スキーマ変更時にTypeScriptは沈黙する。
- 解決策: HonoのRPC——ルートにzValidator(Zod)、通常のimportで型を共有するhc()クライアント。成否を分ける鉄則:型推論にはメソッドチェーン。
- 結果: サーバーでスキーマを変えればクライアントがコンパイル時に赤くなる。さらにapp.route()でのルート分割とモノレポでの型共有の方法も。
短い答え: HonoのRPCは、コード生成なしでサーバーのルートからクライアントの呼び出し箇所まで型をそのまま流す——サーバーでexport type AppType = typeof routeし、クライアントでhc<AppType>()する。あまり事前に語られない成否の鉄則:TypeScriptが構造全体を推論するには、ルートをメソッドチェーン(.get().post()...)しなければならない。本記事はHono実践記でのRPC紹介を踏み込んで掘り下げる。
TL;DR(エグゼクティブサマリー)
- 課題: フロントが自前のAPIを呼ぶあらゆるアプリで、型はネットワーク境界で切れる。エンドポイントの形を変えれば呼び出し箇所を手で直すことになり、コンパイラは実行時に失敗するまで沈黙する。
- 解決策: ルートを
zValidator(Zod)付きで定義し、そのルートをexport typeし、クライアントでhc()を使う。入力の型(バリデータ)も出力の型(c.json())も推論される。- 結果: サーバーでスキーマを変える → クライアントがコンパイル時に赤くなる。ただし3つの鉄則を守ること:チェーン、ファイル分割時の
app.route()、モノレポ両側のstrict: true。
ここでのRPCとは何か(そしてtRPCとどう違うか)
HonoのRPCは新しいプロトコルではない。ルートは依然として通常のREST——curlでも叩ける。「RPC」は薄い型レイヤーにすぎない:hc()クライアントがサーバーのAppTypeを読み、型付け済みの呼び出しメソッドをfetchの上に公開する。
tRPCとの違い:tRPCはHTTPの上に独自プロトコルを構築する。Honoは素のHTTP/fetchを保つ。エンドポイントを普通のAPIのまま、かつTypeScriptで型安全にしたいなら、Honoのモデルのほうが合う——しかもクライアントをtRPCランタイムに縛らない。
ステップ1:Zodで検証し、型を無料で得る
zValidatorは「ターゲット」(json、query、param、form、header、cookie)とZodスキーマを取る。それを通すとc.req.valid(target)が検証済みデータを型付きで返す:
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const app = new Hono()
.post(
'/notes',
zValidator('json', z.object({ title: z.string(), body: z.string() })),
(c) => {
const data = c.req.valid('json') // { title: string; body: string }
return c.json({ id: 1, ...data }, 201)
}
)
.get(
'/notes',
zValidator('query', z.object({ page: z.coerce.number().default(1) })),
(c) => {
const { page } = c.req.valid('query') // page: number(coerce済み)
return c.json({ page, items: [] }, 200)
}
)
注目すべき二点:スキーマは実行時に不正リクエストを弾き(不正入力には400を返す)、同時にコンパイル時に型を生成する。一つの真実の源、二つの効果だ。そしてc.json(x, 201)で必ずステータスコードを明記しているのに注意——これはクライアント側でレスポンス型を推論するために必要だ(後述)。
ステップ2:最大のハマりどころ——メソッドチェーン必須
ここで最も時間を食う。RPCはapp変数の型から推論する。各ルートを別々の文として書くと、その型は蓄積されない:
// ❌ 間違い — クライアントはルートを一つも見ない
const app = new Hono()
app.get('/notes', handlerA)
app.post('/notes', handlerB)
export type AppType = typeof app // RPC的には空
// ✅ 正しい — 一つの式にチェーンしてからexport
const app = new Hono()
.get('/notes', handlerA)
.post('/notes', handlerB)
export type AppType = typeof app // 両ルートを推論
プロジェクトが大きくなりファイルを分けたくなっても、別々の形に戻さないこと。app.route()で分割し、各ファイルは依然として一本のチェーンにする:
// notes.ts
const notes = new Hono()
.get('/', (c) => c.json([{ id: 1 }]))
.post('/', zValidator('json', schema), (c) => c.json({ ok: true }, 201))
export default notes
// index.ts
import notes from './notes'
const app = new Hono().route('/notes', notes)
export type AppType = typeof app
export default app
exportしたのにクライアントが「型なし」と言うなら——10回中9回はどこかがチェーンから外れている。
ステップ3:hc()クライアント——RPCのように呼ぶ、実体はfetch
クライアントではAppTypeをhcに渡す。パスはルート構造に対応し、メソッドは$get/$postだ:
import { hc } from 'hono/client'
import type { AppType } from './server'
const client = hc<AppType>('https://api.example.com/')
const res = await client.notes.$post({
json: { title: 'Hono学習', body: '中立的なノート' },
})
if (res.ok) {
const note = await res.json() // サーバーのc.json()から型推論
}
// param と query はスキーマがnumberでも常に文字列で渡す
const list = await client.notes.$get({ query: { page: '2' } })
二点に注意:param/queryは常に文字列だ(URLの性質——Zodのcoerceがサーバー側で変換を担う)。そしてレスポンスはハンドラがステータスコード付きのc.json()で返した場合にのみ推論される。ミドルウェアから返したレスポンスはクライアントには見えない——だからRPCではc.notFound()を避け、c.json({ error: '...' }, 404)を使う。
ステップ4:クライアントで型を再利用する
フロントでインターフェースを再宣言する必要はない。InferRequestType/InferResponseTypeでクライアントから直接型を取り出す——フォームやreact-queryとの結線に最適だ:
import type { InferRequestType, InferResponseType } from 'hono/client'
type NewNote = InferRequestType<typeof client.notes.$post>['json']
type NoteRes = InferResponseType<typeof client.notes.$post>
サーバーでZodスキーマを変える → NewNoteもNoteResも連動して変わり、誤用はすべてコンパイル時に赤くなる。これこそ「フロントとバックエンドがズレた」というバグ群をまるごと消すものだ。
モノレポ:推論を壊さない二つの鉄則
サーバーとクライアントが一つのモノレポにあるとき、二つが必須だ:
- 両側の
tsconfig.jsonで**"strict": true**。これがないとRPC推論は静かに壊れる。 - クライアントはサーバーパッケージから
import type { AppType }するだけでよい——実コードをimportせず、サーバーをクライアントにバンドルしない。
// tsconfig.json(クライアント・サーバー両方)
{
"compilerOptions": {
"strict": true
}
}
ルートが多いときにドキュメントで確認した小技:型を先に.d.tsへコンパイルしてクライアントに消費させ、毎回IDEでルートチェーン全体を再推論させないこと。数十〜数百のルートでは、これが滑らかなエディタと重いエディタの分かれ目になる。
よくある質問
Zodは必須か、それとも他のバリデータでもよいか?
Zodは必須ではないが、@hono/zod-validatorが小さく、一つのスキーマから実行時検証と型推論の両方を得られるため最も一般的な選択だ。Honoは対応ミドルウェア経由で他のバリデータ(Valibot、TypeBox…)も支える。核心はライブラリではなく、一つのスキーマに不正データの遮断と型生成の両方を担わせること——その二つの仕事を二つの真実の源に分けないことだ。
クライアントとサーバーが別リポジトリでもこのRPCは動くか?
動く。ただしクライアントがビルド時にAppTypeを取得できることが条件だ。よくある方法は、サーバーの型をパッケージ(または.d.tsファイル)として公開し、クライアントがimport typeすることだ。その型をバージョン管理する必要があるためモノレポよりやや手間だが、本質はコード生成ではなくTypeScriptによる型共有のままだ。両者が独立した周期で変わるなら、OpenAPIの併用も検討に値する。一つのモノレポ内なら直接RPCが最も無駄がない。
本記事はHono実践記のRPC紹介を掘り下げたものだ。Honoを実システムに入れるべきか迷っているなら、Honoを本番で使うべきか?を読んでほしい。型安全なAPI層やインフラ再構築について相談するなら、プロジェクトストーリーやお問い合わせもどうぞ。