tRPC でフロント・バック間の型を共有したら、API 通信がかなり安全になった話

tRPC を導入することでバックエンドとフロントエンドで同じ TypeScript 型定義を共有でき、型ズレによるランタイムエラーがかなり減って開発体験が向上しました。Next.js プロジェクトへの導入手順と感想をまとめます。

TypeScript のコードが表示されたエディタ画面。型定義とAPIルーターの記述がシンタックスハイライト付きで写っている

🙌 結論から

tRPC を個人開発の Next.js プロジェクトに導入したところ、バックエンドで定義した型がフロントエンドにそのまま伝わるようになり、API通信まわりのバグがかなり減りました!

REST API 時代は「バックが返すフィールドを変えたのにフロントで気づかなかった」という問題が定期的に起きていたのですが、それがほぼなくなりました。

TypeScript でフルスタックなプロジェクトを書いている方には、相当おすすめできます(^_^)

💡 tRPC とはなにか

tRPC は TypeScript のプロジェクトでバックエンドとフロントエンドの型定義を共有できるライブラリです。

REST API だと、バックエンドが返す型を別途 types.ts のような型定義ファイルに手書きするか、OpenAPI でスキーマを自動生成するか、というアプローチが必要でした。

tRPC では、バックエンドで書いた関数の引数・戻り値の型がフロントエンドにそのまま伝わります。

GraphQL に近い考え方ですが、スキーマ定義ファイルが不要で TypeScript の型推論だけで完結するのが特徴です!

「フロントとバックが同じリポジトリ(もしくは型を共有できる構成)」が前提になります。Next.js のようなフルスタックフレームワークとの相性がとくに良いです。

👀 Next.js プロジェクトへの導入手順

Next.js App Router での導入手順を紹介します。

まず必要なパッケージをインストールします。

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

バックエンド側で tRPC のベースを作ります。

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

const t = initTRPC.create();

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

ルーターに手続き(procedure)を定義します。

// server/routers/post.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';

export const postRouter = router({
  list: publicProcedure.query(async () => {
    return [{ id: 1, title: 'Hello tRPC' }];
  }),
  byId: publicProcedure
    .input(z.object({ id: z.number() }))
    .query(async ({ input }) => {
      return { id: input.id, title: `Post ${input.id}` };
    }),
});

フロントエンド側の呼び出しはこれだけです。

const { data } = trpc.post.list.useQuery();
const { data: post } = trpc.post.byId.useQuery({ id: 1 });

data の型はバックエンドの戻り値から自動で推論されます。バックエンドの型が変わると、フロントで即座にコンパイルエラーが出ます(`・ω・´)

✅ 実際に使ってみた感想

一番感じたのは「型ズレによるバグを仕込みにくくなった」という安心感です。

REST API の開発では、バックエンドで返すフィールドを追加・削除しても、フロントエンドのコードはコンパイルエラーになりません。

型定義ファイルの更新を忘れると「フロントで存在しないプロパティを参照していた」というバグが本番に出ることがありました(´;ω;`)

tRPC だとその心配がほぼなくなります。バックエンドの型をいじると、フロントでそれを使っている箇所が全部エラーになるので、変更漏れにすぐ気づけます!

また、Zod と組み合わせることでバリデーションをサーバー側に集約できます。フロント側で手動で型ガードを書く手間も大幅に減りました。

🤔 気になる点も正直に書く

tRPC はフロントとバックが TypeScript で同一リポジトリに存在している(もしくは型を共有できる)構成が前提です。

バックエンドとフロントエンドが完全に別リポジトリ・別チームで管理されているプロジェクトでは、素直には使えません。

外部のモバイルクライアントや別チームのシステムに API を提供したい場合は、OpenAPI や GraphQL のほうが向いている場面も多いです。

「TypeScript でフルスタックを完結させる」という構成に最適化されたツールなので、使う前に自分のプロジェクトの構成と照らし合わせておくのがよいと思います(・_・;)

🔐 middlewareで認証を共通化する

procedureごとに認証チェックを書いていたら、同じようなコードがあちこちに散らばってきたので、middlewareでまとめることにしました。

tRPCのmiddlewareは t.procedure.use() でチェーンできる仕組みで、認証済みユーザーだけが呼べる procedure をまとめて定義できます。

// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';

const t = initTRPC.context<Context>().create();

const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  // ctx.user が非nullであることを、この先の procedure に型として伝える
  return next({
    ctx: { user: ctx.user },
  });
});

export const protectedProcedure = t.procedure.use(isAuthed);

publicProcedure の代わりに protectedProcedure を使うだけで、認証チェックが自動的に挟まるようになります。

export const postRouter = router({
  create: protectedProcedure
    .input(z.object({ title: z.string() }))
    .mutation(async ({ ctx, input }) => {
      // ctx.user はもう null ではないと型で保証されている!
      return createPost(ctx.user.id, input.title);
    }),
});

ここで意外と感動したのが、middlewareで next({ ctx: { user: ctx.user } }) と返すと、後続の procedure では ctx.user が「nullかもしれない」型から「必ず存在する」型に絞り込まれることです。

これのおかげで、procedure の中で毎回 if (!ctx.user) throw ... を書く必要がなくなり、認証まわりのコードがだいぶ整理されました(・∀・)

ログ計測用のmiddleware(リクエストの処理時間を測る)なども同じ仕組みで作れるので、「認証」「ロギング」「レート制限」のような横断的な関心事は、まとめてmiddlewareに切り出す運用にしています。

パパ系Webエンジニア〜愛用ガジェット一覧 🛍️

この記事を書いている作業環境のガジェットを楽天ROOMにまとめています ✨ 在宅エンジニアのデスク作りの参考にどうぞ 🙌

🙌 まとめ

tRPC は TypeScript のフルスタック構成で「フロント・バックの型を一元管理したい」という課題にかなりきれいに答えてくれるライブラリです!

個人開発プロジェクトや Next.js でのフルスタック開発であれば、導入コストもそれほど高くなくおすすめです。

「API の型ズレで本番バグを出したことがある」という方は、ぜひ一度試してみてください(^o^)/

※ tRPCのAPIは今も更新されています。最新情報は tRPC公式ドキュメント をご確認ください👀