Full-stack TypeScriptプロジェクトにおけるtRPC活用ガイド:中間スキーマ不要のエンドツーエンドType-safe API

Development tutorial - IT technology blog
Development tutorial - IT technology blog

背景と解決すべき課題

バックエンドチームがレスポンスペイロードの user_id フィールドを id に変更したものの、フロントエンド側には誰もその変更を伝えていなかった――。そしてステージング環境へデプロイした瞬間、アプリはクラッシュします:TypeError: Cannot read properties of undefined。

このような事態は、クライアントとサーバーが分離されたプロジェクトでは日常茶飯事です。従来のRESTを使用する場合、双方はプレーンなJSONを介してやり取りするため、静的に束縛された型契約(コントラクト)が存在しません。これに対処するため、多くのチームは以下の2つのアプローチを選択してきました:

  • Swagger/OpenAPIを記述してコードを自動生成する(Code Generation): エンドポイントを少し修正するたびにビルドスクリプトを実行しなければなりません。また、仕様(spec)ファイルは急速に肥大化し、メンテナンスの負担が非常に大きくなります。
  • GraphQLを採用する: 厳密な型安全性が得られるものの、過剰に重厚になりがちです。社内向けアプリケーション程度であっても、SDLスキーマの記述やリゾルバの構築など、複雑なセットアップコストを受け入れる必要があります。

tRPC は、この課題をより洗練されたアプローチで根本から解決します。中間スキーマも不要、コード生成のためのビルドステップも一切ありません。

tRPCはTypeScriptの型推論(Type Inference)機能を直接活用します。Node.js側で記述したルーターやプロシージャの型が、Reactクライアント側へ自動的にマッピングされます。バックエンド側でフィールド名を変更した瞬間、クライアント側のIDE上で即座に赤の波線(エラー警告)が表示されます。

最近携わった5人体制のダッシュボード開発プロジェクトで、チームはRESTからtRPCへの移行を試みました。その結果は驚くべきものでした。API仕様のすり合わせミーティングに費やしていた時間が週4時間からほぼゼロになり、ペイロードのフィールド名のタイポに起因する細かなバグも完全に撲滅されたのです。

ディレクトリ構成とセットアップ

tRPCの真価を最大限に引き出すには、Monorepo構成(npm workspaces、pnpm、またはTurborepo)が最適です。フロントエンドはバックエンドから型のみをインポートできるため、ビルドされたバンドルファイルにサーバー側のランタイムコードが1バイトも含まれることはありません。推奨されるディレクトリ構成は以下のとおりです:

my-project/
├── client/          # Reactアプリケーション (Vite / Next.js)
├── server/          # Node.jsアプリケーション (Express / Fastify)
└── package.json

サーバー側のコアライブラリと、入力スキーマ検証用の zod をインストールします:

cd server
npm install @trpc/server zod cors express
npm install -D @types/cors @types/express typescript

client/ ディレクトリに移動し、tRPCクライアントとキャッシュ管理用のTanStack Query(React Query)をインストールします:

cd ../client
npm install @trpc/client @trpc/react-query @tanstack/react-query

ステップ別の詳細設定

1. バックエンドでtRPC ContextとRouterをセットアップ

server/src/trpc.ts でtRPCインスタンスを初期化します:

import { initTRPC } from '@trpc/server';

const t = initTRPC.create();

export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;

server/src/routers/user.ts でビジネスロジックを処理するルーターを構築します。Zodがランタイムでのデータ検証と同時に、コンパイル時の入力型を自動推論する仕組みに注目してください:

import { z } from 'zod';
import { router, publicProcedure } from '../trpc';

const users = [
  { id: '1', name: 'Nguyen Van A', role: 'admin' as const },
  { id: '2', name: 'Tran Thi B', role: 'member' as const },
];

export const appRouter = router({
  // Query: 一覧取得
  getUserList: publicProcedure.query(() => {
    return users;
  }),

  // Query: パラメータ検証付きの詳細取得
  getUserById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(({ input }) => {
      const user = users.find((u) => u.id === input.id);
      return user || null;
    }),

  // Mutation: ユーザー新規追加
  createUser: publicProcedure
    .input(z.object({
      name: z.string().min(2, '名前は2文字以上で入力してください'),
      role: z.enum(['admin', 'member']),
    }))
    .mutation(({ input }) => {
      const newUser = { id: String(Date.now()), ...input };
      users.push(newUser);
      return newUser;
    }),
});

// 重要ポイント: フロントエンドにはルーターの「型(TYPE)」のみをエクスポートする
export type AppRouter = typeof appRouter;

server/src/index.ts でExpressアダプターを使用してHTTPサーバーを作成します:

import express from 'express';
import cors from 'cors';
import * as trpcExpress from '@trpc/server/adapters/express';
import { appRouter } from './routers/user';

const app = express();
app.use(cors());

// すべてのルーターをプレフィックス /trpc に登録
app.use(
  '/trpc',
  trpcExpress.createExpressMiddleware({
    router: appRouter,
  })
);

app.listen(4000, () => {
  console.log('tRPCサーバーが http://localhost:4000/trpc で待機中です');
});

2. フロントエンド(React)側でtRPC Hookを設定

client/src/utils/trpc.ts に型の架け橋となるファイルを作成します:

import { createTRPCReact } from '@trpc/react-query';
// `import type` 構文を使用:コンパイラはJSビルド時にこのインポートを削除します
import type { AppRouter } from '../../../server/src/routers/user';

export const trpc = createTRPCReact<AppRouter>();

ルートコンポーネント client/src/App.tsx にProviderを設定します:

import React, { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from './utils/trpc';
import { UserList } from './components/UserList';

export function App() {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: { refetchOnWindowFocus: false, retry: 1 },
    },
  }));

  const [trpcClient] = useState(() => 
    trpc.createClient({
      links: [
        httpBatchLink({
          url: 'http://localhost:4000/trpc',
        }),
      ],
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <UserList />
      </QueryClientProvider>
    </trpc.Provider>
  );
}

3. 完全なオートコンプリートによるAPI呼び出し

client/src/components/UserList.tsx にデータ表示と操作を行うコンポーネントを記述します:

import React from 'react';
import { trpc } from '../utils/trpc';

export function UserList() {
  // `data` に型 { id: string, name: string, role: 'admin' | 'member' }[] が自動適用される
  const { data, isLoading, error } = trpc.getUserList.useQuery();
  const utils = trpc.useUtils();

  const createMutation = trpc.createUser.useMutation({
    onSuccess: () => {
      // キャッシュを無効化(Invalidate)して最新の一覧を自動再取得
      utils.getUserList.invalidate();
    },
  });

  if (isLoading) return <p>リストを読み込み中...</p>;
  if (error) return <p style={{ color: 'red' }}>エラー: {error.message}</p>;

  return (
    <div>
      <h3>メンバー一覧 ({data?.length || 0})</h3>
      <ul>
        {data?.map((user) => (
          <li key={user.id}>
            {user.name} &mdash; <strong>{user.role}</strong>
          </li>
        ))}
      </ul>
      <button 
        disabled={createMutation.isPending}
        onClick={() => createMutation.mutate({ name: 'Le Van C', role: 'member' })}
      >
        {createMutation.isPending ? '追加中...' : '新規ユーザーを追加'}
      </button>
    </div>
  );
}

テストとプロダクションレディへの備え

ローカル環境でコードが動作したからといって、それはまだ道半ばに過ぎません。ここでは、型安全性を検証し、本番環境にデプロイする前に保護レイヤーを追加する方法を紹介します。

1. 型安全性の「破壊テスト」を実施する

試しに server/src/routers/user.ts を開き、role フィールドの名前を position に変更してみてください。そしてすぐにクライアント側の UserList.tsx タブを確認してみましょう:

user.role のコード行に即座に赤の波線が引かれ、Property 'role' does not exist on type... というエラーメッセージが表示されます。ビルドコマンドを実行する必要すらありません。わずか50ミリ秒のうちに、エディタ上でエラーが捕捉されたのです。

2. 処理時間を計測するミドルウェアの導入

ボトルネックとなっているクエリを早期に検知するために、レスポンス時間をログに記録します。server/src/trpc.ts に以下のミドルウェアを追加します:

import { initTRPC } from '@trpc/server';

const t = initTRPC.create();

const loggingMiddleware = t.middleware(async ({ path, type, next }) => {
  const start = performance.now();
  const result = await next();
  const durationMs = Math.round(performance.now() - start);

  if (result.ok) {
    console.log(`[tRPC] ${type.toUpperCase()} '${path}' 処理完了 (${durationMs}ms)`);
  } else {
    console.error(`[tRPC ERROR] ${type.toUpperCase()} '${path}' 失敗 (${durationMs}ms):`, result.error.message);
  }

  return result;
});

// ロギング機能付きの標準Procedure
export const loggedProcedure = t.procedure.use(loggingMiddleware);

3. TRPCErrorによるエラーコードの標準化

安易に throw new Error() をスローしないでください。クライアント側に機密性の高いスタックトレースが漏洩する恐れがあります。適切なHTTPステータスコードにマッピングするために、TRPCError を使用しましょう:

import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { loggedProcedure } from '../trpc';

export const getProfile = loggedProcedure
  .input(z.object({ token: z.string().min(1) }))
  .query(async ({ input }) => {
    if (input.token !== 'valid-secret-token') {
      throw new TRPCError({
        code: 'UNAUTHORIZED',
        message: 'セッションの有効期限が切れています',
      });
    }
    return { id: '1', email: '[email protected]' };
  });

これにより、クライアント側は error.data?.code === 'UNAUTHORIZED' を通じて正確なエラーコードを受け取ることができます。ログインページへのリダイレクト処理なども安全かつ一貫性をもって実装でき、エッジケースの考慮漏れを防ぐことが可能になります。

Share: