tRPCとTypeScriptでNext.jsに型安全なフルスタックAPIを構築する — スキーマ定義不要

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

問題:APIがあなたを裏切る

バックエンドでフィールド名を変更する。データベースのマイグレーションは正常に完了する。サーバーはエラーなしで起動する。そして2日後、受信トレイにバグレポートが届く——フロントエンドが古いフィールド名を参照したまま、クラッシュしているという内容だ。

これはエッジケースではない。TypeScriptフロントエンドと並行して独立したRESTまたはGraphQL APIを管理しているチームでは、日常的に起きることだ。型はサーバー側に存在する。型はクライアント側にも存在する。しかし、両者が同期し続けることを強制する仕組みはない。コンパイラは偽りの安心感を与えるだけだ。

私はまさにこの壁に、本番のNext.jsプロジェクトでぶつかった。/api/usersエンドポイントがユーザーオブジェクトを返していて、フロントエンドでは手書きのTypeScriptインターフェースで受け取っていた。バックエンドの変更がコードレビューをすり抜けてデプロイされ、15分以内にユーザーが空白のプロフィールページを見ることになった。あの瞬間から、より良い解決策を探し始めた。

根本原因:APIコントラクトが2か所に存在する

これは主にツールの問題ではなく、アーキテクチャの問題だ。バックエンドとフロントエンドがネットワーク境界を挟んで別々の型システムになっているとき、同じコントラクトに対して2つの情報源が存在することになる。REST APIはドキュメントやOpenAPIスペックを通じてデータを記述する。GraphQLはスキーマによってこの問題を部分的に解決しているが、クライアントの型を使えるようにするにはコード生成とビルドステップが必要だ。

各アプローチには固有の摩擦がある:

  • OpenAPIはスペックファイルの管理と、ルート変更のたびにジェネレーターを実行することが必要
  • GraphQLはスキーマ、codegenパイプライン、そして別途クライアントライブラリが必要
  • 手書きのインターフェースは規律と信頼が必要——どちらも3人以上のチームでは機能しない

本当に必要なのは、ネットワークの両端が共有できる唯一の情報源——コード生成ステップが一切不要なもの——だ。

解決策の比較

選択肢1:OpenAPI + コード生成

OpenAPIのYAMLスペックを書いてジェネレーターを実行すれば、クライアント側のTypeScript型が得られる。動作はする。問題は、スペックが実際の実装から乖離していく第三の成果物になることだ。開発者がルートハンドラーを更新してスペックを更新し忘れると、生成された型が間違っているのにコンパイラは黙ったままになる。SwaggerとExpressを使ってAPIドキュメントを自動化するアプローチも、スペックの鮮度管理という課題は残る。

選択肢2:Apolloまたはurqlを使ったGraphQL

GraphQLはランタイムでスキーマを強制し、codegenが型付きクエリを生成する。外部コンシューマーが多い公開APIには、genuinely正しい選択だ。しかし両端を自分たちが所有するプライベートなNext.jsアプリの内部では、オーバーヘッドがすぐに積み重なる:スキーマ定義言語、リゾルバー、codegenの設定、キャッシュ管理。必要のない問題に対して大量のインフラを抱えることになる。

選択肢3:tRPC — 1つのルーター、2つの端点

tRPCは根本的に異なるアプローチをとる。サーバー側でTypeScriptのルーターとしてAPIを定義する。クライアントはそのルーターのだけをインポートし——実装ではなく——コード生成ゼロで完全な自動補完とコンパイル時の安全性を得る。プロシージャ名を変更したり出力の形を変えたりすると、それを呼び出しているすべてのファイルにTypeScriptエラーが即座に現れる。

これが機能するのは、Next.jsがすでに同じTypeScriptモノレポでサーバーとクライアントの両方のコードを実行しているからだ。tRPCはその事実を前提に作られている。

Next.jsプロジェクトにtRPCをセットアップする

以下はゼロからの完全なセットアップだ。私はこれを2つの本番アプリで使っている——1つは約30プロシージャ、もう1つは80近く。どちらの場合も、APIコントラクトのバグがローカル開発を超えることはなかった。

ステップ1:依存関係のインストール

npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zod

Zodは入力バリデーションを処理し、プロシージャ入力のスキーマ層としても機能する。バリデーションを一度書くだけで、同じ定義からランタイムの安全性とTypeScriptの型推論の両方が得られる。

ステップ2:サーバー側でtRPCを初期化する

src/server/trpc.tsを作成する:

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

const t = initTRPC.create();

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

ステップ3:ルーターを定義する

src/server/routers/user.tsを作成する:

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

export const userRouter = router({
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      // 実際のDBコールに置き換えてください
      return {
        id: input.id,
        name: 'Alice',
        email: '[email protected]',
      };
    }),

  create: publicProcedure
    .input(
      z.object({
        name: z.string().min(1),
        email: z.string().email(),
      })
    )
    .mutation(async ({ input }) => {
      // DBに挿入
      return { id: 'new-id', ...input };
    }),
});

次に、src/server/routers/_app.tsでルートルーターを組み立てる:

import { router } from '../trpc';
import { userRouter } from './user';

export const appRouter = router({
  user: userRouter,
});

export type AppRouter = typeof appRouter;

このAppRouter型が、サーバーとクライアントの境界を越える唯一のものだ。実装の詳細は一切漏れない。

ステップ4:Next.jsにAPIハンドラーをマウントする

Pages Routerの場合はsrc/pages/api/trpc/[trpc].tsを、App Routerの場合はsrc/app/api/trpc/[trpc]/route.tsを作成する:

// Pages Router バージョン
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/routers/_app';

export default createNextApiHandler({
  router: appRouter,
  createContext: () => ({}),
});

ステップ5:クライアントを設定する

src/utils/trpc.tsを作成する:

import { createTRPCNext } from '@trpc/next';
import { httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/routers/_app';

export const trpc = createTRPCNext<AppRouter>({
  config() {
    return {
      links: [
        httpBatchLink({
          url: '/api/trpc',
        }),
      ],
    };
  },
});

ステップ6:アプリをラップする

src/pages/_app.tsxに:

import type { AppType } from 'next/app';
import { trpc } from '../utils/trpc';

const MyApp: AppType = ({ Component, pageProps }) => {
  return <Component {...pageProps} />;
};

export default trpc.withTRPC(MyApp);

ステップ7:コンポーネントからプロシージャを呼び出す

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

export default function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading } = trpc.user.getById.useQuery({ id: userId });

  if (isLoading) return <p>読み込み中...</p>;

  return (
    <div>
      <h1>{data?.name}</h1>
      <p>{data?.email}</p>
    </div>
  );
}

では、ルーターのemailemailAddressに変更してみよう。data.emailを参照しているすべてのコンポーネントに即座にTypeScriptエラーが表示される——アプリを実行する前、ブラウザを開く前、ユーザーがクラッシュを目にする前に。

標準で得られるもの

  • プロシージャ呼び出しの自動補完 — エディターが利用可能なすべてのルートとその期待される入力を把握している
  • 型の不一致に対するコンパイル時エラー — サーバー側でフィールドを変更すると、すべての呼び出し元がランタイムではなくビルド時に壊れる
  • 自動リクエストバッチ処理 — 同じレンダリングサイクル内の複数のuseQuery呼び出しが自動的に1つのHTTPリクエストにまとめられる
  • React Query統合 — キャッシング、再フェッチ、ローディング状態が@tanstack/react-query経由で標準搭載
  • コード生成ゼロ — YAMLファイルもビルドスクリプトもリポジトリを散らかす生成フォルダーも不要

tRPCが適さないケース

tRPCが真価を発揮するのは、バックエンドとフロントエンドがTypeScriptモノレポを共有し、1つのチームが両方を所有している場合だ。サードパーティクライアントが利用する公開API、SwiftやKotlinで構築されたモバイルアプリ、またはバックエンドとフロントエンドのリポジトリが別々のスケジュールでデプロイされる状況には向いていない。そのような場合は、OpenAPIやGraphQLの方が現実的な選択となる。

しかし、1つのチームがフルスタックを所有するNext.jsアプリにとっては、tRPCは意味のある複雑さを加えることなくバグの一クラスを丸ごと排除してくれる。初期セットアップは約20分。その後は、コンパイラがコントラクトを強制してくれる——永続的に、自動的に、追加のプロセスを一切必要とせずに。

Share: