2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Hono REST API → tRPC ― 300行のAPIを型安全に置き換えた話

2
Posted at

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駆動開発の実践、技術選定の判断軸、放置プロジェクトの救い方

👉 note.com/ebiyy

2
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?