Hono × Inertia.js が作る新しい型貫通体験に触れてみた
はじめに
// サーバー側(Hono)
app.get('/posts/:id', (c) => {
const post = findPost(c.req.param('id'))
return c.render('Posts/Show', { post })
})
// クライアント側(React)
export default function Show({ post }: PageProps<'Posts/Show'>) {
return <h1>{post.title}</h1>
}
post の型は Post として完全に推論される。間に API 定義も DTO も tRPC もスキーマ生成もない。サーバーで c.render() の第2引数に渡したオブジェクトが、そのまま React の props として、しかも完全に型付きで届く。
「API なし SPA」を謳う Inertia.js プロトコル自体は Laravel や Rails で何年も使われてきた。だが本記事で扱うのはその先の世界だ ── TypeScript と Hono を組み合わせたとき、Inertia は本家ですら実現できない型貫通の体験になる。それも、たった60行のアダプターで。
ぼくも大好きな Hono の作者のwadaさんが注目しており、これは Inertia.js の TypeScript ネイティブ実装が持つ独自のポテンシャルだ。
今回は、wadaさんが公開された Hono × Inertia.js のサンプルアプリを見ながら、なぜここまで透明な fullstack 体験が成立するのか、その仕組みを解き明かす。
Inertia.js とは何か
Inertia.js は「API を作らずに SPA を作る」ための仕組み。
通常 React/Vue で SPA を作る場合、サーバー側に REST/GraphQL API を立て、クライアントから fetch で叩く。Inertia はこの API レイヤを排除する。サーバーは React コンポーネント名と props を JSON で直接返し、クライアントの Inertia ランタイムがそれを描画する。
// サーバーが返す JSON の shape
{
component: 'Posts/Show', // 表示するページ名
props: { post: {...} }, // React に渡すデータ
url: '/posts/1', // 履歴管理用 URL
version: 'abc123' // アセットの整合性確認用
}
これを Inertia は「page object」と呼ぶ。サーバーの責務は「page object を返すこと」だけになり、クライアント側のルーティング、状態管理、fetch ロジックはすべて不要になる。
<Link> をクリックすると X-Inertia: true ヘッダ付きでリクエストが飛び、page object が返ってきて React が新しいコンポーネントに差し替える。SPA の体感はそのまま、API レイヤだけが消える。
公式は自身を「Modern Monolith」と呼ぶ。ログイン後の業務アプリのように SEO が不要で、ページ単位で状態が完結するアプリと相性が良い。
このプロトコル自体は実装言語に依存しない。本記事で参考にする yusukebe/hono-inertia-example では、約60行のミドルウェアで Hono に組み込んでいる。次章では、その薄さがもたらす予想外の恩恵 ── 型貫通する SPA 体験 ── に焦点を当てる。
型貫通の魔法:src/page-props.ts
c.render('Posts/Show', { post }) と書いたサーバーのコードから、なぜクライアントの React コンポーネントが post: Post という型情報を受け取れるのか。
ここで起きているのは、TypeScript の型システムを用いた4段階の伝達だ。順に追ってみよう。
全体図:4段階の型チェーン
[1] c.render が TypedResponse を返す型宣言
↓
[2] Hono が ExtractSchema で全ルートの output を集める
↓
[3] AppRegistry に「うちの app はこれ」と登録
↓
[4] PageProps<C> で union から該当する props を取り出す
Phase1:c.render の戻り値型を仕込む
src/renderer.tsx の中に、こんな型宣言が含まれている。
declare module 'hono' {
interface ContextRenderer {
<C extends PageName, P = Record<string, never>>(
component: C,
props?: P
): Response & TypedResponse<{ component: C; props: P }, 200, 'html'>
}
}
c.render('Posts/Show', { post }) を呼ぶと、TypeScript は generics の C に 'Posts/Show'、P に { post: Post } を保存する。戻り値は TypedResponse<{ component: 'Posts/Show'; props: { post: Post } }> 型として記録される。
この時点でランタイム上は何も起きていない。純粋に型レベルの「レシート発行」が行われたに過ぎない。だが Hono はこのレシートを後で集約できる。
Phase2:ExtractSchema で全ルートを集める
Hono には ExtractSchema<App> という組み込み型ユーティリティがある。これは tRPC の AppRouter 型と同じ仕組みで、ルーターから全ルートの型シグネチャを抽出する。
type Schema = ExtractSchema<typeof app>
// → 概念的にこういう shape:
// {
// '/posts/:id': {
// GET: { output: { component: 'Posts/Show'; props: { post: Post } } }
// },
// '/posts': {
// GET: { output: { component: 'Posts/Index'; props: { posts: Post[] } } }
// },
// ...
// }
サーバー側の c.render(...) 呼び出しから、各ルートが「どの component を、どんな props で返すか」を網羅的にスキーマとして抽出できる。
Phase3:AppRegistry への登録(module augmentation)
しかしクライアント側のヘルパー型は、特定の Hono アプリの型を直接 import できない。サーバーがクライアントを参照し、クライアントがサーバーを参照すると、循環依存になる。
そこでこのサンプルコードでは AppRegistry パターン を採用している。
// src/page-props.ts
export interface AppRegistry {} // 空のインターフェース
type RegisteredApp = AppRegistry extends { app: infer A } ? A : never
// app/pages.gen.ts(Vite plugin が自動生成)
declare module '../src/page-props' {
interface AppRegistry {
app: typeof app // ← ここで「うちの app はこれ」と宣言
}
}
TypeScript の declare module 構文を使うと、後付けでインターフェースに property を追加できる(module augmentation)。Vite plugin が app/server.ts の存在を検知して自動生成する pages.gen.ts が、この紐付けを担う。
「サーバーとクライアントの型システムを、自動生成ファイルが疎結合に繋ぐ」構造になっている。
Phase4:PageProps<C> で props 型を取り出す
ここまでで「全ルートの返り値型がスキーマとして取れる状態」になった。あとは特定のページの props だけを抜き出すだけだ。
type RenderOutput<App> = /* ExtractSchema の output 全部を union として集めた型 */
export type PageProps<C> = Extract<
RenderOutput<RegisteredApp>,
{ component: C }
>['props']
TypeScript の Extract は、union から条件マッチするものだけ抜き出すユーティリティ。PageProps<'Posts/Show'> を計算するとこうなる:
1. union 全体から { component: 'Posts/Show', ... } にマッチする要素を Extract
→ { component: 'Posts/Show'; props: { post: Post } }
2. その ['props'] を取り出す
→ { post: Post }
これで PageProps<'Posts/Show'> が { post: Post } として推論される。サーバー側で書いた1行の c.render から、ここまで型が貫通している。
Laravel / Rails 版との決定的な差
冒頭で引用した「下手すれば Laravel とか Rails で使うより DX がよくなる」という発言は、まさにこの仕組みを指している。
Laravel + Inertia の現実
- スキーマがサーバー側(PHP)とクライアント側(TypeScript)で二重定義
- クライアントの props 型は手動で書く(型推論は言語をまたげない)
- ページ名のタイポは実行時まで気付かない
Hono + Inertia
- スキーマがサーバー側に1箇所のみ
- クライアントの props 型は完全自動推論
- ページ名のタイポは
PageNameの union 型でコンパイルエラー
これは「TypeScript ネイティブで全層書く」体制でなければ成立しない優位性で、長年 Inertia を使ってきた Laravel・Rails ユーザーが羨むべき体験だ。
tRPC との粒度の違い
「型貫通だけが目的なら tRPC でいいのでは」と感じるかもしれない。確かに似た系譜の技術だが、粒度が異なる。
| tRPC | Hono + Inertia | |
|---|---|---|
| 型が流れる対象 | 個別の API procedure | ページ全体の props |
| クライアント呼び出し | trpc.posts.get.useQuery() |
<Link href="/posts/1"> |
| ルーティング | クライアント側にも router | サーバー側のみ |
| データ取得タイミング | コンポーネント単位で fetch | ページ遷移で一括 |
tRPC は「API としての関数を型付きで呼ぶ」、Inertia は「ページ全体を型付きで受け取る」。前者がコンポーネントレベルの粒度なら、後者はページレベルの粒度。
業務アプリのように「ページが状態の単位」となるアプリでは、Inertia の粒度の方が直感的に書ける場面が多い。
体感してみる
app/pages/Posts/Show.tsx を開いて、post. の後にドットを押してみてほしい。autocomplete に id、title、body が出てくる。これは──サーバー側で posts: Post[] と書いたデータ型が、ここまで何ら手作業の宣言を経ずに届いている証拠だ。
試しにサーバー側を c.render('Posts/Show', { post: post.title }) のように間違って渡せば、即座にコンパイルエラーになる。サーバーとクライアントが、本当に型で繋がっている。
これが、TypeScript と Hono が組み合わさって生まれた Inertia の新しい姿だ。
まとめ ── 何が新しいのか
Hono × Inertia.js × TypeScript の組み合わせは、Laravel/Rails 版 Inertia ですら届かなかった型貫通する SPA 体験を実現する。改めてその差を整理しよう。
| Laravel + Inertia | Hono + Inertia | |
|---|---|---|
| サーバー言語 | PHP | TypeScript |
| スキーマ定義の場所 | 二重(PHP + TS) | サーバー側のみ |
| クライアント props 型 | 手動 | 自動推論 |
| ページ名のタイポ検知 | 実行時 | コンパイル時 |
これは「TypeScript で全層書く」体制でしか成立しない優位性だ。Hono の透明性哲学(書いたコードが動く、抽象を最小化する)と Inertia の薄いプロトコル(4プロパティの page object)が出会ったとき、自然に立ち上がった。アダプター本体はわずか60行に収まる。
追記:公式 middleware 化が進行中
ちなみに本記事を書いている最中に、wadaさん自身により @hono/inertia として Hono の公式 middleware に格上げする PR が公開された (honojs/middleware#1867)。merge されれば、本記事で読み解いたロジックがそのまま npm i @hono/inertia で使えるようになる。
公式版でも page-props.ts の型貫通ロジックは本記事で扱った内容とほぼ同一で、アダプター本体に rootView オプションや 409 Conflict でのアセットバージョン整合性チェックなどが追加される見込みだ。本記事の解説は引き続き有効なので、公式版が来たら API 名(renderer() → inertia()、import 元の差し替え)だけ脳内で読み替えてほしい。
Hono × Inertia.js は、まだ走り始めたばかりだ。
Discussion