trpc-type-safety について
フルスタック TypeScript アプリを開発する人向けの tRPC 実践ガイドです。コード生成なしのエンドツーエンド型推論を中心に、ルーター、検証、クライアント連携、本番運用のパターンを扱います。
主な機能
- 01エンドツーエンドの TypeScript 型推論
- 02ルーターとプロシージャの設計
- 03Zod による入力検証
- 04コンテキストとミドルウェアのパターン
- 05React Query クライアント連携
活用シーン
- 01TypeScript アプリの内部 API 設計
- 02検証付きクエリとミューテーションの追加
- 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 の型推論だけで実現できます。
主な構成要素
- ルーター:プロシージャ(API エンドポイント)のコレクション
- プロシージャ:単一の API 操作(クエリまたはミューテーション)
- コンテキスト:リクエスト単位のデータ(ユーザー、データベースなど)
- ミドルウェア:リクエストのインターセプト/変更(認証、ロギング)
- 入力/出力: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 メソッド | GET | POST |
| キャッシュ | 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_REQUEST | 400 | 無効な入力 |
UNAUTHORIZED | 401 | 認証されていない |
FORBIDDEN | 403 | 認可されていない |
NOT_FOUND | 404 | リソースが見つからない |
TIMEOUT | 408 | リクエストがタイムアウトした |
CONFLICT | 409 | リソースが競合している |
PRECONDITION_FAILED | 412 | 前提条件を満たしていない |
PAYLOAD_TOO_LARGE | 413 | リクエストが大きすぎる |
TOO_MANY_REQUESTS | 429 | レート制限を超過した |
CLIENT_CLOSED_REQUEST | 499 | クライアントが接続を閉じた |
INTERNAL_SERVER_ERROR | 500 | サーバーエラー |
カスタムエラー処理
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 との比較
機能の比較
| 機能 | tRPC | REST | GraphQL |
|---|---|---|---|
| 型安全性 | 完全(TypeScript) | 手動/コード生成 | 手動/コード生成 |
| コード生成 | なし | 任意(OpenAPI) | 必須 |
| 学習曲線 | 低い | 低い | 中程度/高い |
| クライアントライブラリ | TypeScript のみ | 任意の言語 | 任意の言語 |
| API ドキュメント | TypeScript の型 | OpenAPI/Swagger | スキーマ/イントロスペクション |
| パブリック API | ❌ いいえ | ✅ はい | ✅ はい |
| 柔軟なクエリ | ❌ 固定 | ❌ 固定 | ✅ はい |
| 過剰取得 | 最小限 | よくある | なし |
| キャッシュ | React Query | HTTP キャッシュ | 複雑 |
| リアルタイム | WebSocket | SSE/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 からの移行
段階的な移行戦略
- REST と併せて tRPC を追加する:すべてを一度に書き換えないでください
- 新機能には tRPC を使う:新しいエンドポイントから始めます
- 価値の高いエンドポイントを移行する:複雑な API や頻繁に使われる API に注力します
- パブリック 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
パフォーマンスのヒント
-
複数のクエリにはバッチ処理を使う:
httpBatchLink({ url: '/api/trpc', maxBatchSize: 10 }) -
N+1 クエリには DataLoader を実装する:
const userLoader = new DataLoader(batchLoadUsers); -
コストの高いクエリをキャッシュする:
trpc.posts.list.useQuery(undefined, { staleTime: 5 * 60 * 1000 }); -
データベースクエリを最適化する:
// ❌ 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 }, }); -
React Query の重複排除を使う:
// Multiple components can call same query—React Query deduplicates const { data } = trpc.user.getMe.useQuery();
セキュリティのベストプラクティス
- 必ず Zod で入力を検証する
- 認証にはミドルウェアを使う:
const protectedProcedure = t.procedure.use(isAuthed); - 本番環境ではエラーメッセージをサニタイズする
- レート制限を実装する
- 本番環境では HTTPS を使う
- CORS を適切に設定する:
createNextApiHandler({ router: appRouter, createContext, onError: ({ error }) => { if (error.code === 'INTERNAL_SERVER_ERROR') { console.error('Internal error:', error); } }, });
型安全性のヒント
-
実装ではなくルーターの型をエクスポートする:
export type AppRouter = typeof appRouter; // ✅ // Don't export `appRouter` itself to client -
より良い推論には
satisfiesを使う:const input = { name: 'Alice', age: 30, } satisfies CreateUserInput; -
コンテキストで
anyを避ける:// ❌ Bad ctx: { user: any } // ✅ Good ctx: { user: User | null }
開発ワークフロー
- まずスキーマを定義する:プロシージャの前に Zod スキーマを記述します
- プロシージャを個別にテストする:単体テストには
createCallerを使います - TypeScript の strict モードを使う:型エラーを早期に検出します
- 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(統合)