mcpField
T
trpc-type-safety

trpc-type-safety

github.com/bobmatnyc開発781.0k 回インストール15 週間前に更新
リポジトリ

trpc-type-safety について

フルスタック TypeScript アプリを開発する人向けの tRPC 実践ガイドです。コード生成なしのエンドツーエンド型推論を中心に、ルーター、検証、クライアント連携、本番運用のパターンを扱います。

主な機能

  1. 01エンドツーエンドの TypeScript 型推論
  2. 02ルーターとプロシージャの設計
  3. 03Zod による入力検証
  4. 04コンテキストとミドルウェアのパターン
  5. 05React Query クライアント連携

活用シーン

  1. 01TypeScript アプリの内部 API 設計
  2. 02検証付きクエリとミューテーションの追加
  3. 03React クライアントと tRPC サーバーの接続

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill trpc-type-safety

インストール後、Claude Code で/trpc-type-safetyを入力して読み込まれたか確認します。

同梱ファイルmetadata.json

詳細

tRPC - エンドツーエンドの型安全性


progressive_disclosure: entry_point: summary sections: - id: summary title: "tRPC の概要" tokens: 70 next: [when_to_use, quick_start] - id: when_to_use title: "tRPC を使用する場面" tokens: 150 next: [quick_start, core_concepts] - id: quick_start title: "クイックスタート" tokens: 300 next: [core_concepts, router_definition] - id: core_concepts title: "基本概念" tokens: 400 next: [router_definition, procedures] - id: router_definition title: "ルーターの定義" tokens: 350 next: [procedures, context] - id: procedures title: "プロシージャ(クエリとミューテーション)" tokens: 400 next: [input_validation, context] - id: input_validation title: "Zod による入力検証" tokens: 350 next: [context, middleware] - id: context title: "コンテキスト管理" tokens: 400 next: [middleware, error_handling] - id: middleware title: "ミドルウェア" tokens: 400 next: [error_handling, client_setup] - id: error_handling title: "エラー処理" tokens: 350 next: [client_setup, react_integration] - id: client_setup title: "クライアントのセットアップ" tokens: 400 next: [react_integration, nextjs_integration] - id: react_integration title: "React Query との統合" tokens: 450 next: [nextjs_integration, subscriptions] - id: nextjs_integration title: "Next.js App Router との統合" tokens: 500 next: [subscriptions, file_uploads] - id: subscriptions title: "リアルタイムサブスクリプション" tokens: 400 next: [file_uploads, batching] - id: file_uploads title: "ファイルのアップロード" tokens: 300 next: [batching, typescript_inference] - id: batching title: "リクエストのバッチ処理とデータローダー" tokens: 350 next: [typescript_inference, testing] - id: typescript_inference title: "TypeScript の型推論パターン" tokens: 300 next: [testing, production_patterns] - id: testing title: "テスト戦略" tokens: 400 next: [production_patterns, comparison] - id: production_patterns title: "本番環境でのパターン" tokens: 450 next: [comparison, migration] - id: comparison title: "REST および GraphQL との比較" tokens: 250 next: [migration, best_practices] - id: migration title: "REST からの移行" tokens: 300 next: [best_practices] - id: best_practices title: "ベストプラクティスとパフォーマンス" tokens: 400

概要

tRPC を使うと、コード生成なしで TypeScript クライアントとサーバー間のエンドツーエンドの型安全性を実現できます。API を一度定義するだけで、あらゆる場所で型が自動的に推論されます。

主なメリット:コード生成不要、TypeScript の型推論、React Query との統合、最小限の定型コード。


tRPC を使用する場面

✅ 最適な用途:

  • フルスタックの TypeScript アプリケーション(Next.js、T3 スタック)
  • クライアントとサーバーで TypeScript のコードベースを共有するプロジェクト
  • GraphQL のような型安全性と REST のようなシンプルさを求めるチーム
  • データ取得に React Query を使用するアプリ
  • クライアントとサーバーの両方を管理できる内部 API

❌ 避けるべき用途:

  • TypeScript 以外のクライアントから利用される公開 API
  • 異なる言語で構築されたマイクロサービス
  • Swift/Kotlin を使うモバイルアプリ(代わりに REST/GraphQL を使用)
  • 外部開発者向けの API ドキュメントが必要な場合(OpenAPI のほうが適しています)

選択の目安:

  • tRPC:フルスタックの TypeScript、モノレポ、内部ツール
  • REST:公開 API、言語に依存しない設計、幅広い互換性
  • GraphQL:複雑なデータグラフ、複数のクライアント、柔軟なクエリ

クイックスタート

インストール
# Server dependencies
npm install @trpc/server zod

# React/Next.js client dependencies
npm install @trpc/client @trpc/react-query @tanstack/react-query
ルーターの定義(サーバー)
// server/trpc.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

export const appRouter = t.router({
  hello: t.procedure
    .input(z.object({ name: z.string() }))
    .query(({ input }) => {
      return { greeting: `Hello ${input.name}` };
    }),

  createPost: t.procedure
    .input(z.object({ title: z.string(), content: z.string() }))
    .mutation(async ({ input }) => {
      // Save to database
      return { id: 1, ...input };
    }),
});

export type AppRouter = typeof appRouter;
クライアントでの使用(React)
// client/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/trpc';

export const trpc = createTRPCReact<AppRouter>();

// Component
function MyComponent() {
  const { data } = trpc.hello.useQuery({ name: 'World' });
  const createPost = trpc.createPost.useMutation();

  return <div>{data?.greeting}</div>; // Fully typed!
}

次へ:基本概念を学ぶか、ルーターの定義に進んでください。


基本概念

tRPC の哲学

tRPC は、クライアントとサーバー間で TypeScript の型を共有することで、型安全なリモートプロシージャコールを提供します。コード生成は不要で、TypeScript の型推論だけで実現できます。

主な構成要素
  1. ルーター:プロシージャ(API エンドポイント)のコレクション
  2. プロシージャ:単一の API 操作(クエリまたはミューテーション)
  3. コンテキスト:リクエスト単位のデータ(ユーザー、データベースなど)
  4. ミドルウェア:リクエストのインターセプト/変更(認証、ロギング)
  5. 入力/出力:Zod スキーマで検証
型の流れ
// Server defines types
const router = t.router({
  getUser: t.procedure
    .input(z.string())
    .query(({ input }) => ({ id: input, name: 'Alice' })),
});

// Client gets automatic types
const user = await trpc.getUser.query('123');
// user is typed as { id: string, name: string }
アーキテクチャパターン
┌─────────────┐     Type-safe     ┌──────────────┐
│   Client    │ ←────────────────→ │    Server    │
│ (React)     │   No codegen!      │   (Node.js)  │
└─────────────┘                    └──────────────┘
      ↓                                    ↓
 React Query                          tRPC Router
 (caching)                            (procedures)

メリット:

  • 変更がすぐに反映されます(ビルド工程は不要です)
  • 名前変更のリファクタリングがクライアント/サーバー間で機能します
  • 型の誤った呼び出しを防げます
  • すべての API メソッドで自動補完を利用できます

ルーターの定義

基本的なルーター構造
import { initTRPC } from '@trpc/server';

const t = initTRPC.create();

export const appRouter = t.router({
  // Procedures go here
});

export type AppRouter = typeof appRouter;
ネストされたルーター(名前空間)
const userRouter = t.router({
  getById: t.procedure
    .input(z.string())
    .query(({ input }) => getUser(input)),

  create: t.procedure
    .input(z.object({ name: z.string(), email: z.string() }))
    .mutation(({ input }) => createUser(input)),
});

const postRouter = t.router({
  list: t.procedure.query(() => getPosts()),
  create: t.procedure
    .input(z.object({ title: z.string() }))
    .mutation(({ input }) => createPost(input)),
});

export const appRouter = t.router({
  user: userRouter,
  post: postRouter,
});

// Client usage:
// trpc.user.getById.useQuery('123')
// trpc.post.list.useQuery()
ルーターの統合
import { adminRouter } from './admin';
import { publicRouter } from './public';

export const appRouter = t.mergeRouters(publicRouter, adminRouter);
ルーター構成のベストプラクティス
server/
├── trpc.ts           # tRPC instance, context, middleware
├── routers/
│   ├── user.ts       # User-related procedures
│   ├── post.ts       # Post-related procedures
│   └── index.ts      # Combine all routers
└── index.ts          # Export AppRouter type

プロシージャ(クエリとミューテーション)

クエリプロシージャ(読み取り操作)
const router = t.router({
  // Simple query
  getUser: t.procedure
    .input(z.string())
    .query(({ input }) => {
      return db.user.findUnique({ where: { id: input } });
    }),

  // Query with multiple inputs
  searchUsers: t.procedure
    .input(z.object({
      query: z.string(),
      limit: z.number().default(10),
    }))
    .query(({ input }) => {
      return db.user.findMany({
        where: { name: { contains: input.query } },
        take: input.limit,
      });
    }),
});
ミューテーションプロシージャ(書き込み操作)
const router = t.router({
  createUser: t.procedure
    .input(z.object({
      name: z.string().min(3),
      email: z.string().email(),
    }))
    .mutation(async ({ input }) => {
      return await db.user.create({ data: input });
    }),

  updateUser: t.procedure
    .input(z.object({
      id: z.string(),
      data: z.object({
        name: z.string().optional(),
        email: z.string().email().optional(),
      }),
    }))
    .mutation(async ({ input }) => {
      return await db.user.update({
        where: { id: input.id },
        data: input.data,
      });
    }),
});
クエリとミューテーションの比較
項目クエリミューテーション
目的データの読み取りデータの変更
HTTP メソッドGETPOST
キャッシュReact Query がキャッシュキャッシュされない
冪等性ありなし
副作用なしデータベースへの書き込み、メール送信など
出力の型付け
const router = t.router({
  getUser: t.procedure
    .input(z.string())
    .output(z.object({ id: z.string(), name: z.string() })) // Optional
    .query(({ input }) => {
      return { id: input, name: 'Alice' };
    }),
});

注:出力の検証には実行時のオーバーヘッドが伴うため、重要なデータにのみ使用してください。


Zod による入力検証

Zod を使う理由

tRPC は、実行時の型検証と TypeScript の型推論に Zod を使用します。Zod スキーマには、次の利点があります。

  • 実行時の検証(無効なデータを防止)
  • TypeScript の型(スキーマから自動推論)
  • 変換(解析、型の強制、デフォルト値)
基本的な検証
import { z } from 'zod';

const router = t.router({
  createPost: t.procedure
    .input(z.object({
      title: z.string().min(5).max(100),
      content: z.string(),
      published: z.boolean().default(false),
      tags: z.array(z.string()).optional(),
    }))
    .mutation(({ input }) => {
      // input is fully typed and validated
      return createPost(input);
    }),
});
高度な検証
const createUserInput = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  age: z.number().int().min(18),
  role: z.enum(['user', 'admin']),
  metadata: z.record(z.string(), z.unknown()).optional(),
});

const router = t.router({
  createUser: t.procedure
    .input(createUserInput)
    .mutation(({ input }) => {
      // All validation passed
      return saveUser(input);
    }),
});
変換
const router = t.router({
  getUser: t.procedure
    .input(
      z.object({
        id: z.string().transform((id) => parseInt(id, 10)),
      })
    )
    .query(({ input }) => {
      // input.id is now a number
      return db.user.findUnique({ where: { id: input.id } });
    }),
});
再利用可能なスキーマ
// schemas/user.ts
export const CreateUserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
});

export const UpdateUserSchema = CreateUserSchema.partial().extend({
  id: z.string(),
});

// routers/user.ts
const router = t.router({
  create: t.procedure.input(CreateUserSchema).mutation(/*...*/),
  update: t.procedure.input(UpdateUserSchema).mutation(/*...*/),
});

コンテキスト管理

コンテキストとは?

コンテキストは、すべてのプロシージャにリクエスト単位のデータ(認証、データベース接続、ロギングなど)を提供します。

コンテキストの作成
import { inferAsyncReturnType } from '@trpc/server';
import { CreateNextContextOptions } from '@trpc/server/adapters/next';

export async function createContext(opts: CreateNextContextOptions) {
  const session = await getSession(opts.req);

  return {
    session,
    db: prisma,
    req: opts.req,
    res: opts.res,
  };
}

export type Context = inferAsyncReturnType<typeof createContext>;

const t = initTRPC.context<Context>().create();
プロシージャでのコンテキストの使用
const router = t.router({
  getMe: t.procedure.query(({ ctx }) => {
    if (!ctx.session?.user) {
      throw new TRPCError({ code: 'UNAUTHORIZED' });
    }

    return ctx.db.user.findUnique({
      where: { id: ctx.session.user.id },
    });
  }),

  createPost: t.procedure
    .input(z.object({ title: z.string() }))
    .mutation(async ({ ctx, input }) => {
      return ctx.db.post.create({
        data: {
          title: input.title,
          authorId: ctx.session.user.id,
        },
      });
    }),
});
コンテキストのベストプラクティス
// ✅ Good: Lazy database connection
export async function createContext(opts: CreateNextContextOptions) {
  return {
    getDB: () => prisma, // Lazy
    session: await getSession(opts.req),
  };
}

// ❌ Bad: Heavy computation in context
export async function createContext(opts: CreateNextContextOptions) {
  const allUsers = await prisma.user.findMany(); // Too expensive!
  return { allUsers };
}

ミドルウェア

ミドルウェアとは?

ミドルウェアはプロシージャの呼び出しをインターセプトし、ロギング、処理時間の計測、認証、レート制限などの横断的関心事を追加します。

基本的なミドルウェア
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
  const start = Date.now();
  console.log(`→ ${type} ${path}`);

  const result = await next();

  const duration = Date.now() - start;
  console.log(`✓ ${type} ${path} - ${duration}ms`);

  return result;
});

const loggedProcedure = t.procedure.use(loggerMiddleware);
認証ミドルウェア
const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }

  return next({
    ctx: {
      ...ctx,
      user: ctx.session.user, // Narrow type
    },
  });
});

// Protected procedure builder
const protectedProcedure = t.procedure.use(isAuthed);

const router = t.router({
  // Public
  getPublicPosts: t.procedure.query(() => getPosts()),

  // Protected - requires authentication
  getMyPosts: protectedProcedure.query(({ ctx }) => {
    // ctx.user is guaranteed to exist
    return getPostsByUser(ctx.user.id);
  }),
});
ミドルウェアの連結
const timingMiddleware = t.middleware(async ({ next }) => {
  const start = performance.now();
  const result = await next();
  console.log(`Execution time: ${performance.now() - start}ms`);
  return result;
});

const rateLimitMiddleware = t.middleware(async ({ ctx, next }) => {
  await checkRateLimit(ctx.session?.user?.id);
  return next();
});

const protectedProcedure = t.procedure
  .use(timingMiddleware)
  .use(rateLimitMiddleware)
  .use(isAuthed);
コンテキストの変換
const enrichContextMiddleware = t.middleware(async ({ ctx, next }) => {
  const user = ctx.session?.user
    ? await ctx.db.user.findUnique({ where: { id: ctx.session.user.id } })
    : null;

  return next({
    ctx: {
      ...ctx,
      user, // Full user object
    },
  });
});

エラー処理

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

const router = t.router({
  getUser: t.procedure
    .input(z.string())
    .query(async ({ input }) => {
      const user = await db.user.findUnique({ where: { id: input } });

      if (!user) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: `User ${input} not found`,
        });
      }

      return user;
    }),
});
エラーコード
コードHTTP ステータス使用例
BAD_REQUEST400無効な入力
UNAUTHORIZED401認証されていない
FORBIDDEN403認可されていない
NOT_FOUND404リソースが見つからない
TIMEOUT408リクエストがタイムアウトした
CONFLICT409リソースが競合している
PRECONDITION_FAILED412前提条件を満たしていない
PAYLOAD_TOO_LARGE413リクエストが大きすぎる
TOO_MANY_REQUESTS429レート制限を超過した
CLIENT_CLOSED_REQUEST499クライアントが接続を閉じた
INTERNAL_SERVER_ERROR500サーバーエラー
カスタムエラー処理
const router = t.router({
  deleteUser: t.procedure
    .input(z.string())
    .mutation(async ({ input, ctx }) => {
      try {
        return await ctx.db.user.delete({ where: { id: input } });
      } catch (error) {
        if (error.code === 'P2025') { // Prisma not found
          throw new TRPCError({
            code: 'NOT_FOUND',
            message: 'User not found',
            cause: error,
          });
        }
        throw new TRPCError({
          code: 'INTERNAL_SERVER_ERROR',
          message: 'Failed to delete user',
          cause: error,
        });
      }
    }),
});
エラーのフォーマット
const t = initTRPC.context<Context>().create({
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
            ? error.cause.flatten()
            : null,
      },
    };
  },
});
クライアント側のエラー処理
function MyComponent() {
  const mutation = trpc.createUser.useMutation({
    onError: (error) => {
      if (error.data?.code === 'UNAUTHORIZED') {
        router.push('/login');
      } else {
        toast.error(error.message);
      }
    },
  });
}

クライアントのセットアップ

Vanilla クライアント
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from './server';

const client = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000/api/trpc',
    }),
  ],
});

// Usage
const user = await client.user.getById.query('123');
const newPost = await client.post.create.mutate({ title: 'Hello' });
React クライアントのセットアップ
// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/routers';

export const trpc = createTRPCReact<AppRouter>();

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

export default function App({ Component, pageProps }: AppProps) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: 'http://localhost:3000/api/trpc',
        }),
      ],
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <Component {...pageProps} />
      </QueryClientProvider>
    </trpc.Provider>
  );
}
Next.js API ルート
// pages/api/trpc/[trpc].ts
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/routers';
import { createContext } from '../../../server/context';

export default createNextApiHandler({
  router: appRouter,
  createContext,
});
ヘッダーと認証
const trpcClient = trpc.createClient({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000/api/trpc',
      headers: async () => {
        const token = await getAuthToken();
        return {
          authorization: token ? `Bearer ${token}` : undefined,
        };
      },
    }),
  ],
});

React Query との統合

useQuery フック
function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading, error } = trpc.user.getById.useQuery(userId);

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return <div>{data.name}</div>;
}
クエリオプション
const { data } = trpc.posts.list.useQuery(undefined, {
  refetchOnWindowFocus: false,
  staleTime: 5 * 60 * 1000, // 5 minutes
  cacheTime: 10 * 60 * 1000, // 10 minutes
  retry: 3,
  onSuccess: (data) => console.log('Fetched', data.length, 'posts'),
});
useMutation フック
function CreatePostForm() {
  const utils = trpc.useContext();

  const createPost = trpc.post.create.useMutation({
    onSuccess: () => {
      // Invalidate and refetch
      utils.post.list.invalidate();
    },
  });

  const handleSubmit = (data: { title: string }) => {
    createPost.mutate(data);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" />
      <button disabled={createPost.isLoading}>
        {createPost.isLoading ? 'Creating...' : 'Create'}
      </button>
      {createPost.error && <p>{createPost.error.message}</p>}
    </form>
  );
}
楽観的更新
const createPost = trpc.post.create.useMutation({
  onMutate: async (newPost) => {
    // Cancel outgoing refetches
    await utils.post.list.cancel();

    // Snapshot previous value
    const previousPosts = utils.post.list.getData();

    // Optimistically update
    utils.post.list.setData(undefined, (old) => [
      ...(old ?? []),
      { id: 'temp', ...newPost },
    ]);

    return { previousPosts };
  },
  onError: (err, newPost, context) => {
    // Rollback on error
    utils.post.list.setData(undefined, context?.previousPosts);
  },
  onSettled: () => {
    // Refetch after success or error
    utils.post.list.invalidate();
  },
});
無限クエリ
// Server
const router = t.router({
  posts: t.procedure
    .input(z.object({
      cursor: z.number().optional(),
      limit: z.number().default(10),
    }))
    .query(({ input }) => {
      const posts = getPosts(input.cursor, input.limit);
      return {
        posts,
        nextCursor: posts.length === input.limit ? input.cursor + input.limit : undefined,
      };
    }),
});

// Client
function PostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = trpc.posts.useInfiniteQuery(
    { limit: 10 },
    {
      getNextPageParam: (lastPage) => lastPage.nextCursor,
    }
  );

  return (
    <div>
      {data?.pages.map((page) =>
        page.posts.map((post) => <PostCard key={post.id} post={post} />)
      )}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          Load More
        </button>
      )}
    </div>
  );
}

Next.js App Router との統合

サーバーコンポーネント
// app/users/page.tsx (Server Component)
import { createCaller } from '../server/routers';
import { createContext } from '../server/context';

export default async function UsersPage() {
  const ctx = await createContext({ req: null, res: null });
  const caller = createCaller(ctx);

  const users = await caller.user.list();

  return (
    <div>
      {users.map((user) => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  );
}
サーバーアクション
// app/actions.ts
'use server';

import { createCaller } from '../server/routers';
import { createContext } from '../server/context';

export async function createPost(formData: FormData) {
  const ctx = await createContext({ req: null, res: null });
  const caller = createCaller(ctx);

  return caller.post.create({
    title: formData.get('title') as string,
    content: formData.get('content') as string,
  });
}
App Router プロバイダー
// app/providers.tsx
'use client';

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

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: '/api/trpc',
        }),
      ],
    })
  );

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

// app/layout.tsx
import { Providers } from './providers';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
App Router のクライアントコンポーネント
// app/posts/create-button.tsx
'use client';

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

export function CreatePostButton() {
  const createPost = trpc.post.create.useMutation();

  return (
    <button onClick={() => createPost.mutate({ title: 'New Post' })}>
      Create Post
    </button>
  );
}
API ルートハンドラー(App Router)
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '../../../../server/routers';
import { createContext } from '../../../../server/context';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext,
  });

export { handler as GET, handler as POST };

リアルタイムサブスクリプション

WebSocket のセットアップ(サーバー)
import { applyWSSHandler } from '@trpc/server/adapters/ws';
import ws from 'ws';

const wss = new ws.Server({ port: 3001 });

applyWSSHandler({
  wss,
  router: appRouter,
  createContext,
});

console.log('WebSocket server listening on port 3001');
サブスクリプションプロシージャ
import { observable } from '@trpc/server/observable';
import { EventEmitter } from 'events';

const ee = new EventEmitter();

const router = t.router({
  onPostAdd: t.procedure.subscription(() => {
    return observable<Post>((emit) => {
      const onAdd = (data: Post) => emit.next(data);

      ee.on('add', onAdd);

      return () => {
        ee.off('add', onAdd);
      };
    });
  }),

  createPost: t.procedure
    .input(z.object({ title: z.string() }))
    .mutation(({ input }) => {
      const post = { id: Date.now().toString(), ...input };
      ee.emit('add', post); // Emit to subscribers
      return post;
    }),
});
クライアントの WebSocket セットアップ
import { createWSClient, wsLink } from '@trpc/client';

const wsClient = createWSClient({
  url: 'ws://localhost:3001',
});

const trpcClient = trpc.createClient({
  links: [
    wsLink({
      client: wsClient,
    }),
  ],
});
useSubscription フック
function PostFeed() {
  const [posts, setPosts] = useState<Post[]>([]);

  trpc.onPostAdd.useSubscription(undefined, {
    onData: (post) => {
      setPosts((prev) => [post, ...prev]);
    },
    onError: (err) => {
      console.error('Subscription error:', err);
    },
  });

  return (
    <div>
      {posts.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}
入力を伴うサブスクリプション
// Server
const router = t.router({
  onUserStatusChange: t.procedure
    .input(z.string())
    .subscription(({ input }) => {
      return observable<UserStatus>((emit) => {
        const onChange = (userId: string, status: UserStatus) => {
          if (userId === input) {
            emit.next(status);
          }
        };

        ee.on('statusChange', onChange);
        return () => ee.off('statusChange', onChange);
      });
    }),
});

// Client
trpc.onUserStatusChange.useSubscription('user-123', {
  onData: (status) => console.log('Status:', status),
});

ファイルのアップロード

マルチパートフォームデータ(サーバー)
// Next.js API route with file upload
import { NextApiRequest, NextApiResponse } from 'next';
import formidable from 'formidable';
import fs from 'fs';

export const config = {
  api: { bodyParser: false },
};

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const form = formidable({ multiples: false });

  form.parse(req, async (err, fields, files) => {
    if (err) return res.status(500).json({ error: 'Upload failed' });

    const file = files.file as formidable.File;
    const buffer = fs.readFileSync(file.filepath);

    // Upload to S3, etc.
    const url = await uploadToS3(buffer, file.originalFilename);

    res.json({ url });
  });
}
Base64 アップロード(tRPC)
// For small files only (<1MB)
const router = t.router({
  uploadAvatar: t.procedure
    .input(z.object({
      fileName: z.string(),
      fileData: z.string(), // Base64
    }))
    .mutation(async ({ input }) => {
      const buffer = Buffer.from(input.fileData, 'base64');
      const url = await uploadToS3(buffer, input.fileName);
      return { url };
    }),
});

// Client
const uploadAvatar = trpc.uploadAvatar.useMutation();

const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  const file = e.target.files?.[0];
  if (!file) return;

  const reader = new FileReader();
  reader.onload = () => {
    const base64 = reader.result as string;
    uploadAvatar.mutate({
      fileName: file.name,
      fileData: base64.split(',')[1], // Remove data:image/...;base64,
    });
  };
  reader.readAsDataURL(file);
};
署名付き URL パターン(推奨)
// Step 1: Get signed upload URL from tRPC
const router = t.router({
  getUploadUrl: t.procedure
    .input(z.object({
      fileName: z.string(),
      fileType: z.string(),
    }))
    .mutation(async ({ input }) => {
      const signedUrl = await s3.getSignedUrl('putObject', {
        Bucket: 'my-bucket',
        Key: input.fileName,
        ContentType: input.fileType,
        Expires: 60, // 1 minute
      });

      return { uploadUrl: signedUrl, fileUrl: `https://cdn.example.com/${input.fileName}` };
    }),
});

// Step 2: Client uploads directly to S3
async function uploadFile(file: File) {
  // Get signed URL
  const { uploadUrl, fileUrl } = await trpc.getUploadUrl.mutate({
    fileName: file.name,
    fileType: file.type,
  });

  // Upload directly to S3
  await fetch(uploadUrl, {
    method: 'PUT',
    body: file,
    headers: { 'Content-Type': file.type },
  });

  // Save file URL to database via tRPC
  await trpc.user.updateAvatar.mutate({ url: fileUrl });
}

バッチリクエストと DataLoader

自動バッチ処理
// Client configuration
const trpcClient = trpc.createClient({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000/api/trpc',
      maxBatchSize: 10, // Batch up to 10 requests
    }),
  ],
});

// Multiple calls made close together are batched into one HTTP request
const user1 = trpc.user.getById.useQuery('1');
const user2 = trpc.user.getById.useQuery('2');
const user3 = trpc.user.getById.useQuery('3');
// → Single HTTP request with 3 procedure calls
DataLoader パターン
import DataLoader from 'dataloader';

// Create DataLoader in context
export async function createContext() {
  const userLoader = new DataLoader(async (ids: readonly string[]) => {
    const users = await db.user.findMany({
      where: { id: { in: [...ids] } },
    });

    // Return in same order as input
    return ids.map((id) => users.find((u) => u.id === id));
  });

  return { userLoader };
}

// Use in procedures
const router = t.router({
  getUser: t.procedure
    .input(z.string())
    .query(({ ctx, input }) => {
      return ctx.userLoader.load(input); // Batched!
    }),

  getPosts: t.procedure.query(async ({ ctx }) => {
    const posts = await db.post.findMany({ take: 10 });

    // N+1 problem solved—all authors fetched in one query
    const postsWithAuthors = await Promise.all(
      posts.map(async (post) => ({
        ...post,
        author: await ctx.userLoader.load(post.authorId),
      }))
    );

    return postsWithAuthors;
  }),
});
条件付きバッチ処理
import { httpBatchLink, httpLink, splitLink } from '@trpc/client';

const trpcClient = trpc.createClient({
  links: [
    splitLink({
      // Batch queries, don't batch mutations
      condition: (op) => op.type === 'query',
      true: httpBatchLink({ url: '/api/trpc' }),
      false: httpLink({ url: '/api/trpc' }),
    }),
  ],
});

TypeScript の型推論パターン

ルーターから型を推論する
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from './server';

// Input types
type RouterInputs = inferRouterInputs<AppRouter>;
type CreateUserInput = RouterInputs['user']['create'];

// Output types
type RouterOutputs = inferRouterOutputs<AppRouter>;
type User = RouterOutputs['user']['getById'];

// Use in components
function UserCard({ user }: { user: User }) {
  return <div>{user.name}</div>;
}
プロシージャヘルパー
import type { inferProcedureInput, inferProcedureOutput } from '@trpc/server';

type CreatePostInput = inferProcedureInput<AppRouter['post']['create']>;
type Post = inferProcedureOutput<AppRouter['post']['getById']>;
コンテキストの型推論
import { inferAsyncReturnType } from '@trpc/server';

export async function createContext() {
  return {
    db: prisma,
    user: null as User | null,
  };
}

export type Context = inferAsyncReturnType<typeof createContext>;

const t = initTRPC.context<Context>().create();
ジェネリックプロシージャ
// Reusable pagination
function createPaginatedProcedure<T>(
  getData: (cursor: number, limit: number) => Promise<T[]>
) {
  return t.procedure
    .input(z.object({
      cursor: z.number().optional(),
      limit: z.number().default(10),
    }))
    .query(async ({ input }) => {
      const items = await getData(input.cursor ?? 0, input.limit);
      return {
        items,
        nextCursor: items.length === input.limit
          ? (input.cursor ?? 0) + input.limit
          : undefined,
      };
    });
}

const router = t.router({
  posts: createPaginatedProcedure((cursor, limit) =>
    db.post.findMany({ skip: cursor, take: limit })
  ),
  users: createPaginatedProcedure((cursor, limit) =>
    db.user.findMany({ skip: cursor, take: limit })
  ),
});

テスト戦略

プロシージャの単体テスト
import { createCaller } from '../routers';

describe('User Router', () => {
  it('should create user', async () => {
    const ctx = {
      db: mockDb,
      session: null,
    };

    const caller = createCaller(ctx);

    const result = await caller.user.create({
      name: 'Alice',
      email: '[email protected]',
    });

    expect(result).toMatchObject({
      name: 'Alice',
      email: '[email protected]',
    });
  });
});
統合テスト
import { httpBatchLink } from '@trpc/client';
import { createTRPCProxyClient } from '@trpc/client';
import type { AppRouter } from '../server';

describe('tRPC Integration', () => {
  const client = createTRPCProxyClient<AppRouter>({
    links: [
      httpBatchLink({
        url: 'http://localhost:3000/api/trpc',
      }),
    ],
  });

  it('should fetch user', async () => {
    const user = await client.user.getById.query('123');
    expect(user.id).toBe('123');
  });
});
コンテキストのモック化
import { createCaller } from '../routers';

const mockContext = {
  db: {
    user: {
      findUnique: vi.fn().mockResolvedValue({ id: '1', name: 'Alice' }),
      create: vi.fn(),
    },
  },
  session: {
    user: { id: '1', email: '[email protected]' },
  },
};

it('should get current user', async () => {
  const caller = createCaller(mockContext);
  const user = await caller.user.getMe();

  expect(mockContext.db.user.findUnique).toHaveBeenCalledWith({
    where: { id: '1' },
  });
  expect(user.name).toBe('Alice');
});
React フックのテスト
import { renderHook, waitFor } from '@testing-library/react';
import { createWrapper } from './test-utils';

it('should fetch posts', async () => {
  const { result } = renderHook(() => trpc.post.list.useQuery(), {
    wrapper: createWrapper(),
  });

  await waitFor(() => expect(result.current.isSuccess).toBe(true));

  expect(result.current.data).toHaveLength(10);
});

// test-utils.ts
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '../utils/trpc';

export function createWrapper() {
  const queryClient = new QueryClient();
  const trpcClient = trpc.createClient({
    links: [httpBatchLink({ url: 'http://localhost:3000/api/trpc' })],
  });

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

本番環境でのパターン

エラー監視
import * as Sentry from '@sentry/node';

const t = initTRPC.context<Context>().create({
  errorFormatter({ shape, error }) {
    // Log to Sentry
    if (error.code === 'INTERNAL_SERVER_ERROR') {
      Sentry.captureException(error);
    }

    return {
      ...shape,
      data: {
        ...shape.data,
        // Don't expose internal errors in production
        message: process.env.NODE_ENV === 'production' && error.code === 'INTERNAL_SERVER_ERROR'
          ? 'Internal server error'
          : shape.message,
      },
    };
  },
});
レート制限
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'),
});

const rateLimitMiddleware = t.middleware(async ({ ctx, next }) => {
  const identifier = ctx.session?.user?.id ?? ctx.req.ip;
  const { success } = await ratelimit.limit(identifier);

  if (!success) {
    throw new TRPCError({
      code: 'TOO_MANY_REQUESTS',
      message: 'Rate limit exceeded',
    });
  }

  return next();
});
キャッシュ
import { Redis } from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

const router = t.router({
  getUser: t.procedure
    .input(z.string())
    .query(async ({ input }) => {
      // Check cache
      const cached = await redis.get(`user:${input}`);
      if (cached) return JSON.parse(cached);

      // Fetch from database
      const user = await db.user.findUnique({ where: { id: input } });

      // Cache for 5 minutes
      await redis.setex(`user:${input}`, 300, JSON.stringify(user));

      return user;
    }),
});
リクエストのログ記録
const loggingMiddleware = t.middleware(async ({ path, type, next, input }) => {
  const start = Date.now();

  console.log(`→ ${type} ${path}`, { input });

  try {
    const result = await next();
    const duration = Date.now() - start;
    console.log(`✓ ${type} ${path} - ${duration}ms`);
    return result;
  } catch (error) {
    const duration = Date.now() - start;
    console.error(`✗ ${type} ${path} - ${duration}ms`, { error });
    throw error;
  }
});
OpenTelemetry の統合
import { trace } from '@opentelemetry/api';

const tracingMiddleware = t.middleware(async ({ path, type, next }) => {
  const tracer = trace.getTracer('trpc');

  return tracer.startActiveSpan(`trpc.${type}.${path}`, async (span) => {
    try {
      const result = await next();
      span.setStatus({ code: 0 }); // OK
      return result;
    } catch (error) {
      span.setStatus({ code: 2, message: error.message }); // ERROR
      span.recordException(error);
      throw error;
    } finally {
      span.end();
    }
  });
});

REST および GraphQL との比較

機能の比較
機能tRPCRESTGraphQL
型安全性完全(TypeScript)手動/コード生成手動/コード生成
コード生成なし任意(OpenAPI)必須
学習曲線低い低い中程度/高い
クライアントライブラリTypeScript のみ任意の言語任意の言語
API ドキュメントTypeScript の型OpenAPI/Swaggerスキーマ/イントロスペクション
パブリック API❌ いいえ✅ はい✅ はい
柔軟なクエリ❌ 固定❌ 固定✅ はい
過剰取得最小限よくあるなし
キャッシュReact QueryHTTP キャッシュ複雑
リアルタイムWebSocketSSE/WebSocketサブスクリプション
ファイルのアップロード回避策ネイティブ対応複雑
それぞれを選ぶ場面

tRPC:

  • ✅ フルスタック TypeScript のモノレポ
  • ✅ 内部ツールやダッシュボード
  • ✅ Next.js アプリケーション
  • ✅ 小規模なチームによる迅速な開発
  • ❌ 外部利用者向けのパブリック API
  • ❌ 複数言語のクライアント

REST:

  • ✅ 幅広い互換性が求められるパブリック API
  • ✅ 複数言語のサービス
  • ✅ HTTP キャッシュの要件がある場合
  • ✅ ファイルのアップロードとダウンロード
  • ❌ 複雑なネスト構造のデータ
  • ❌ コード生成なしで型安全性が必要な場合

GraphQL:

  • ✅ 複雑なデータグラフ
  • ✅ 複数のクライアント種別(Web、モバイルなど)
  • ✅ 柔軟なクエリが必要な場合
  • ✅ 過剰取得を避けたい場合
  • ❌ シンプルな CRUD 操作
  • ❌ 小規模なチーム(複雑さによる負担)
移行パス

tRPC は REST/GraphQL と共存できます:

// Use tRPC for internal, REST for public
const router = t.router({
  internal: internalRouter, // tRPC only
});

// Expose REST endpoints separately
app.get('/api/public/users', publicRestHandler);

REST からの移行

段階的な移行戦略
  1. REST と併せて tRPC を追加する:すべてを一度に書き換えないでください
  2. 新機能には tRPC を使う:新しいエンドポイントから始めます
  3. 価値の高いエンドポイントを移行する:複雑な API や頻繁に使われる API に注力します
  4. パブリック API は REST のままにする:内部での利用のみを移行します
REST から tRPC への変換

変更前(REST):

// pages/api/users/[id].ts
export default async function handler(req, res) {
  if (req.method === 'GET') {
    const user = await db.user.findUnique({ where: { id: req.query.id } });
    res.json(user);
  } else if (req.method === 'PATCH') {
    const user = await db.user.update({
      where: { id: req.query.id },
      data: req.body,
    });
    res.json(user);
  }
}

// Client
const response = await fetch(`/api/users/${id}`);
const user = await response.json(); // No types!

変更後(tRPC):

// server/routers/user.ts
export const userRouter = t.router({
  getById: t.procedure
    .input(z.string())
    .query(({ input }) => db.user.findUnique({ where: { id: input } })),

  update: t.procedure
    .input(z.object({
      id: z.string(),
      data: z.object({ name: z.string().optional() }),
    }))
    .mutation(({ input }) => db.user.update({
      where: { id: input.id },
      data: input.data,
    })),
});

// Client
const user = await trpc.user.getById.query(id); // Fully typed!
共有バリデーション
// Reuse Zod schemas across REST and tRPC during migration
import { createUserSchema } from '../schemas/user';

// tRPC
const router = t.router({
  createUser: t.procedure
    .input(createUserSchema)
    .mutation(({ input }) => createUser(input)),
});

// REST (validate with same schema)
export default async function handler(req, res) {
  const parsed = createUserSchema.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({ errors: parsed.error });
  }
  const user = await createUser(parsed.data);
  res.json(user);
}

ベストプラクティスとパフォーマンス

コードの整理
server/
├── trpc.ts              # tRPC instance, base procedures
├── context.ts           # Context creation
├── middleware/
│   ├── auth.ts          # Authentication middleware
│   ├── logging.ts       # Logging middleware
│   └── rateLimit.ts     # Rate limiting
├── routers/
│   ├── _app.ts          # Root router
│   ├── user.ts          # User procedures
│   ├── post.ts          # Post procedures
│   └── admin/
│       └── index.ts     # Admin-only procedures
└── schemas/
    ├── user.ts          # User Zod schemas
    └── post.ts          # Post Zod schemas
パフォーマンスのヒント
  1. 複数のクエリにはバッチ処理を使う:

    httpBatchLink({ url: '/api/trpc', maxBatchSize: 10 })
    
  2. N+1 クエリには DataLoader を実装する:

    const userLoader = new DataLoader(batchLoadUsers);
    
  3. コストの高いクエリをキャッシュする:

    trpc.posts.list.useQuery(undefined, { staleTime: 5 * 60 * 1000 });
    
  4. データベースクエリを最適化する:

    // ❌ Bad: N+1 query
    const posts = await db.post.findMany();
    const postsWithAuthors = await Promise.all(
      posts.map((p) => db.user.findUnique({ where: { id: p.authorId } }))
    );
    
    // ✅ Good: Single query with include
    const posts = await db.post.findMany({
      include: { author: true },
    });
    
  5. React Query の重複排除を使う:

    // Multiple components can call same query—React Query deduplicates
    const { data } = trpc.user.getMe.useQuery();
    
セキュリティのベストプラクティス
  1. 必ず Zod で入力を検証する
  2. 認証にはミドルウェアを使う:
    const protectedProcedure = t.procedure.use(isAuthed);
    
  3. 本番環境ではエラーメッセージをサニタイズする
  4. レート制限を実装する
  5. 本番環境では HTTPS を使う
  6. CORS を適切に設定する:
    createNextApiHandler({
      router: appRouter,
      createContext,
      onError: ({ error }) => {
        if (error.code === 'INTERNAL_SERVER_ERROR') {
          console.error('Internal error:', error);
        }
      },
    });
    
型安全性のヒント
  1. 実装ではなくルーターの型をエクスポートする:

    export type AppRouter = typeof appRouter; // ✅
    // Don't export `appRouter` itself to client
    
  2. より良い推論には satisfies を使う:

    const input = {
      name: 'Alice',
      age: 30,
    } satisfies CreateUserInput;
    
  3. コンテキストで any を避ける:

    // ❌ Bad
    ctx: { user: any }
    
    // ✅ Good
    ctx: { user: User | null }
    
開発ワークフロー
  1. まずスキーマを定義する:プロシージャの前に Zod スキーマを記述します
  2. プロシージャを個別にテストする:単体テストには createCaller を使います
  3. TypeScript の strict モードを使う:型エラーを早期に検出します
  4. React Query DevTools を有効にする:
    import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
    
    <ReactQueryDevtools initialIsOpen={false} />
    
よくある落とし穴

❌ 機密データを返さないでください:

// Bad: Exposes password hash
.query(() => db.user.findMany())

// Good: Select specific fields
.query(() => db.user.findMany({ select: { id: true, name: true } }))

❌ 読み取りにミューテーションを使わないでください:

// Bad: Side-effect-free operation as mutation
getMostRecentPost: t.procedure.mutation(() => getPost())

// Good: Use query for reads
getMostRecentPost: t.procedure.query(() => getPost())

❌ 入力検証を省略しないでください:

// Bad: No validation
.input(z.any())

// Good: Strict validation
.input(z.object({ id: z.string().uuid() }))
監視と可観測性
const t = initTRPC.context<Context>().create({
  errorFormatter({ shape, error }) {
    // Log metrics
    metrics.increment('trpc.error', { code: error.code });

    // Send to error tracking
    if (error.code === 'INTERNAL_SERVER_ERROR') {
      Sentry.captureException(error);
    }

    return shape;
  },
});

const loggingMiddleware = t.middleware(async ({ path, type, next }) => {
  const start = Date.now();
  const result = await next();

  // Log performance metrics
  metrics.timing('trpc.duration', Date.now() - start, { path, type });

  return result;
});

まとめ

tRPC を使うと、最小限のボイラープレートで型安全な API を構築できます:

  • ✅ コード生成不要:TypeScript から型が推論されます
  • ✅ React Query との統合:キャッシュと楽観的更新が組み込まれています
  • ✅ Next.js を第一級でサポート:App Router、Server Components
  • ✅ 開発者体験:自動補完、リファクタリング、型エラー

最適な用途:フルスタック TypeScript アプリ、Next.js プロジェクト、社内ツール 避けるべき用途:公開 API、多言語サービス

始め方:インストール → ルーターを定義 → クライアントで使用 → 型安全性を活用!

関連スキル:Zod(検証)、React Query(キャッシュ)、Next.js(統合)

コメント