Honoの型推論は好きだけどRPCの書き方が苦手な人のための、RESTな型安全クライアント「hono-typed-rest」を作った
Honoを使っていると、サーバー側で定義したルーティングの型(AppType)をフロントエンドで使い回せる「Hono RPC」の便利さに感動します。しかし、その独自の書き方に戸惑いを感じたことはないでしょうか?
今回は、Honoの型推論の恩恵を最大限に受けつつ、使い慣れたRESTの書き方で通信できるライブラリ 「hono-typed-rest」 を作ったので、その紹介をします!
なんで作ろうと思ったか
RPCのDX(開発体験)は好きだけど、書き方が嫌い
Hono公式の hc (Hono Client) は非常に強力ですが、client.api.user.$get() のようなRPCスタイルのメソッドチェーンで記述する必要があります。
個人的には、以下のような理由で少し苦手意識がありました。
-
「REST APIを叩いている」という直感から外れる: 普通の
fetchやaxiosのようにclient.get('/path')と書きたい。 -
階層が深いと記述が長くなる:
client.api.v1.internal.users.posts.$get()のように、ディレクトリ構造に縛られた長いメソッドチェーンを辿るのが大変。
REST的な書き方で自動的な型推論が欲しかった
tRPCやoRPC、あるいはHono RPCのような「型が勝手に付いてくる」体験は最高です。一方で、フロントエンドでAPIラッパーを作る際、毎回以下のようにDTO(データ転送オブジェクト)を手動で割り当てるのが非常に面倒でした。
// 毎回ジェネリクスで型を指定するのがだるい...
export const listCases = (params: ListInput) =>
apiClient.get<CaseListResponse>("/case/list", { query: params });
「パスを入力したら、引数の型も戻り値の型も自動で決まってほしい。でも書き方はRESTがいい。」というワガママを解決するために開発を始めました。
どうやって実現したか
このライブラリは、Honoの AppType に含まれる複雑な型情報を TypeScript の型パズルで分解することで実現しています。
1. Schemaの抽出
Honoのアプリインスタンス(typeof app)から、ルーティング情報を保持している Schema 型を抽出します。@hono/zod-openapi を使っている高度な定義でも、標準的な Hono インスタンスでも同様に扱えるように工夫しています。
2. 2xxレスポンスの自動抽出
ここが一番のこだわりポイントです。Honoの型定義には、エラー時(400や404など)のレスポンス型も含まれていることがあります。
hono-typed-rest では、型レベルで 「2xx系のステータスコードを持つ出力」だけをフィルタリングして抽出 しています。
おかげで、成功時のデータ型をわざわざ定義したりインポートしたりする手間が一切なくなります。 型安全の恩恵だけをタダで受け取れる感じです。
3. パスパラメータの実行時置換
client.get('/post/:id', { params: { id: '123' } }) のように、パスに含まれる :id などのパラメータを動的に置換するロジックを内蔵しています。これにより、URLの組み立てミスも防げます。
作ってみてどうか
控えめに言って最高です。
実際に自分のプロジェクトに導入してみたところ、API呼び出しのヘルパーがここまでシンプルになりました。
import { createRestClient } from "hono-typed-rest";
import type { ApiAppType } from "@/server/api/app";
// クライアントの作成
export const apiClient = createRestClient<ApiAppType>({ baseUrl: "/api" });
/**
* API呼び出しヘルパー
*/
export const caseApi = {
// res は自動的に { items: Case[], total: number } のような型に推論される!
list: (params?: { q?: string }) =>
apiClient.get("/case/list", { query: params ?? {} }),
// 内部で json の型が自動推論されるため、定義と異なる値を渡すと型エラーになります。
// ラッパー関数を定義する場合は引数に型注釈が必要ですが、
// サーバー側の定義と同期されているためリファクタリングにも強いです。
create: (input: CreateCaseInput) =>
apiClient.post("/case", { json: input }),
};
良かった点
- パスの補完が効く: エディタ上でパスがサジェストされるので、ドキュメントを確認する回数が減った。
- リファクタリングに強い: サーバー側でフィールド名を変えたら即座にフロント側で型エラーが出る。
-
「ただのREST」として扱える: 標準的な
fetchベースなので、既存のエコシステムやカスタム fetch をそのまま活用できます。
正直いまいちな点(トレードオフと今後の課題)
-
パスパラメータの指定方法が少しもどかしい
/post/:idのようなパスに対して、以下のように指定します。client.get('/post/:id', { params: { id: '123' } })これ、「第一引数はただの文字列なのに、その中のキーワードを第二引数で指定する」 というのが、型安全ではあるものの、書き味としてはいまいち「直感的でない」と感じています。
理想は${id}とテンプレートリテラルで書きたいところですが、Honoの型定義が/post/:idという文字列に紐付いている以上、現状はこの形が限界でした。 -
原則「成功時はJSONオンリー」
現状は成功レスポンスをJSONとして受け取ることに最適化しています。textやblob(画像など)を返すAPIの場合、自動推論がうまく働かなかったり、手動でのキャストが必要になる場面があります。
一方で、エラー(4xx/5xx)に関しては柔軟で、サーバーが返してきたのが JSON でも HTML でも適切にキャプチャして保持するように実装しています。 -
204 / 205 / HEAD への厳しさ
「成功=JSONが返ってくる」という前提を強く持たせているため、中身が空っぽの204,205レスポンスやHEADリクエストはデフォルトでResponseParseError(パース失敗)扱いになります。
回避するには明示的にallowEmptyBody: trueを渡す必要があり、少し面倒です。 -
実行時のバリデーションはない
あくまでfetchの型レベルラッパーなので、サーバーから返ってきたデータが本当に型通りかどうかを実行時にチェック(Zodなどでパース)はしていません。
「RPCスタイルは馴染めないけど、型安全なREST通信がしたい」という方は、ぜひリポジトリを覗いてみてください。
おわりに:まさかのHono作者からの反応
今回のライブラリ開発で一番驚いたのが、HonoのGitHub Discussionに投稿したところ、作者のyusukebeさんから「Very interesting」というコメントを頂き、さらにXでも紹介して頂けたことです。
さらに、リポジトリの方にも直々にIssueを頂きました。
fetchオプションを渡せるので、Honoのapp.requestを使ったテストや、Cloudflare Workers内での内部呼び出しも簡単。
この記事のメリットでも触れたこの機能は、実はyusukebeさんにIssueでアドバイスを頂いて修正・追加した内容です。
自分が「これ欲しい!」と思って作ったものに対して、本家の方から具体的な改善提案や反応を頂けるのは、個人開発者としてこれ以上ないほど励みになりました。Honoの型定義をそのまま再利用しているからこそ、エコシステムの一部として面白いと思って頂けたのかもしれません。
これからも、Honoの型安全性をより広い層が享受できるようなツールに育てていければと思います。
Discussion