Hono REST API → tRPC ― 300行のAPIを型安全に置き換えた話
はじめに
Cloudflare Workers + Hono で書いた REST API を tRPC に移行した。結果、フロントエンドとバックエンドで型定義を共有でき、APIの呼び出しが格段に楽になった。
移行前は「APIレスポンスの型は自分で定義する」「エンドポイントURLは文字列で管理」という状態。tRPC移行後は trpc.characters.list() と書くだけで型推論が効く。
環境
- Cloudflare Workers
- Hono(Webフレームワーク)
- Drizzle ORM + D1
- tRPC v11
- pnpm workspaces(モノレポ)
Before: Hono REST API
APIサーバー側
// api/src/v1/chapters.ts(297行)
app.get('/', async (c) => {
const db = drizzle(c.env.DB);
const result = await db.select().from(chapters);
return c.json({ success: true, data: result });
});
app.get('/:id', async (c) => {
const id = Number(c.req.param('id'));
// ... 複雑なJOINと整形処理
return c.json({ success: true, data: chapter });
});
フロントエンド側
// app/src/services/api/types.ts(手動で型定義)
export interface Chapter {
id: number;
title: string;
content: string;
type: 'main' | 'side' | 'event';
// ... 50行以上の型定義
}
// app/src/services/api/chapters.ts
export async function getChapters(): Promise<Chapter[]> {
const res = await fetch(`${API_BASE}/v1/chapters`);
const json = await res.json();
return json.data; // ← 型安全ではない
}
問題点:
- 型定義がバックエンドと二重管理
- APIレスポンスの形式変更時に同期漏れリスク
-
fetch()の戻り値はany、手動でキャストが必要
After: tRPC
パッケージ構成
cc-card/
├── trpc/ # @cc-card/trpc(新規作成)
│ └── src/
│ ├── trpc.ts # tRPCインスタンス
│ ├── context.ts # D1/KVバインディング
│ ├── client.ts # クライアント生成
│ └── router/
│ ├── index.ts # appRouter
│ ├── masters.ts
│ ├── characters.ts
│ └── chapters.ts
├── api/ # Cloudflare Workers
│ └── src/
│ ├── index.ts # Hono + tRPCハンドラ
│ └── trpc.ts # ハンドラ生成
└── app/ # Next.js
└── src/
└── trpc/
└── client.ts # クライアント設定
tRPCサーバー側
// trpc/src/router/chapters.ts
export const chaptersRouter = router({
list: publicProcedure.query(async ({ ctx }) => {
const result = await ctx.db.select().from(chapters);
return result; // 型が自動推論される
}),
byId: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ ctx, input }) => {
const result = await ctx.db
.select()
.from(chapters)
.where(eq(chapters.id, input.id));
return result[0] ?? null;
}),
});
Honoとの統合
// api/src/index.ts
import { Hono } from 'hono';
import { createTrpcHandler } from './trpc';
const app = new Hono<{ Bindings: Env }>();
// tRPC エンドポイント
app.all('/trpc/*', async (c) => {
const handler = createTrpcHandler(c.env);
return handler(c.req.raw);
});
export default app;
// api/src/trpc.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter, createContext } from '@cc-card/trpc';
export function createTrpcHandler(env: Env) {
return (request: Request) =>
fetchRequestHandler({
endpoint: '/trpc',
req: request,
router: appRouter,
createContext: () => createContext({
db: drizzle(env.DB),
kv: env.KV,
}),
});
}
フロントエンド側
// app/src/trpc/client.ts
import { createClient } from '@cc-card/trpc/client';
export const trpc = createClient(process.env.NEXT_PUBLIC_API_BASE_URL);
// app/src/app/chapters/page.tsx
const chapters = await trpc.chapters.list();
// ^? Chapter[] — 型が自動推論される!
const chapter = await trpc.chapters.byId({ id: 1 });
// ^? Chapter | null — input も型チェックされる
移行のポイント
1. superjsonでDateをシリアライズ
tRPCはデフォルトでJSONシリアライズを使うが、Date オブジェクトは文字列になってしまう。superjsonを使うと Date をそのまま扱える。
// trpc/src/client.ts
import superjson from 'superjson';
export function createClient(baseUrl: string) {
return createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${baseUrl}/trpc`,
transformer: superjson, // ← これ
}),
],
});
}
2. Cloudflare Workersのバインディング
D1やKVなどのCloudflare Workersバインディングは、リクエストごとに渡す必要がある。tRPCの createContext で注入する。
// trpc/src/context.ts
export interface Context {
db: DrizzleDB;
kv?: KVNamespace;
}
export function createContext({ db, kv }: CreateContextOptions): Context {
return { db, kv };
}
3. モノレポでの型共有
@cc-card/trpc パッケージから AppRouter 型をエクスポートし、フロントエンドでインポートする。これでend-to-endの型安全が実現。
// trpc/src/router/index.ts
export const appRouter = router({
masters: mastersRouter,
characters: charactersRouter,
chapters: chaptersRouter,
});
export type AppRouter = typeof appRouter; // ← これをエクスポート
削除できたもの
| 削除したファイル | 行数 |
|---|---|
| api/src/v1/chapters.ts | 297行 |
| api/src/v1/characters.ts | 150行 |
| api/src/services/api/masters.ts | 63行 |
| app/src/services/api/types.ts | 80行 |
| app/src/domain/story/api.ts | 40行 |
合計約630行削除、代わりにtRPCルーター約350行を追加。実質280行の削減に加え、型安全性が向上。
移行で詰まったところ
Cloudflare Workers環境でのtRPC
tRPC v11はCloudflare Workersに対応しているが、アダプターの選択に注意が必要。
// NG: Node.js向けアダプター
import { createHTTPHandler } from '@trpc/server/adapters/standalone';
// OK: Fetch API向けアダプター(Workers互換)
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
HonoのRequestをtRPCに渡す
Honoの c.req は独自のラッパーオブジェクト。tRPCには標準の Request を渡す必要がある。
// NG
return handler(c.req);
// OK
return handler(c.req.raw); // ← .raw で標準Requestを取得
まとめ
| 項目 | Before (REST) | After (tRPC) |
|---|---|---|
| 型安全性 | 手動管理 | 自動推論 |
| API呼び出し | fetch(url) |
trpc.xxx.yyy() |
| 型定義 | フロント/バック両方 | バックエンドのみ |
| コード量 | 約630行 | 約350行 |
REST APIからtRPCへの移行は、特にモノレポ構成で大きなメリットがある。バックエンドの型定義がそのままフロントエンドで使えるので、「APIの型が古い」「レスポンス形式が変わった」といった事故を防げる。
本記事の内容は tRPC v11 + Cloudflare Workers で動作確認済みです。
参考
noteでは「AI × 技術 × ビジネス」視点で書いています
Qiitaでは純粋な技術Tipsを、noteでは「その技術をどう活かすか」というPM/事業視点の記事を書いています。
- Qiita: ハマりポイント、解決策、設定方法
- note: AI駆動開発の実践、技術選定の判断軸、放置プロジェクトの救い方