tRPC + Zod でフロントとバックエンドの型を共有する
tRPC は TypeScript のフルスタックフレームワークで、Zod でAPIの入出力スキーマを定義するとフロントエンドに型が自動で伝わります。APIクライアントを手書きせず、型安全なAPI呼び出しができます。
早わかりまとめ
| 役割 | 担当 |
|---|---|
| API ルートの定義 | tRPC router(サーバー) |
| 入力のバリデーション | Zod スキーマ |
| 型の共有 | TypeScript 推論(自動) |
| クライアントの型補完 | tRPC クライアントが自動で解決 |
インストール
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
サーバー側:router を定義する
// server/router.ts
import { initTRPC } from '@trpc/server'
import { z } from 'zod'
const t = initTRPC.create()
export const appRouter = t.router({
// ユーザー取得
getUser: t.procedure
.input(z.object({ id: z.number() }))
.query(({ input }) => {
// input.id は number 型が保証されている
return { id: input.id, name: 'Alice', email: 'alice@example.com' }
}),
// ユーザー作成
createUser: t.procedure
.input(z.object({
name: z.string().min(1),
email: z.string().email(),
}))
.mutation(({ input }) => {
// input は { name: string; email: string } 型
return { id: 1, ...input }
}),
})
export type AppRouter = typeof appRouter
クライアント側:型補完が効く状態で呼び出す
// client.ts
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from './server/router'
export const trpc = createTRPCReact<AppRouter>()
// components/UserProfile.tsx import { trpc } from '@/client' const UserProfile = ({ userId }: { userId: number }) => { // input の型は { id: number } と推論される const { data } = trpc.getUser.useQuery({ id: userId }) return <div>{data?.name}</div> }
// components/CreateUserForm.tsx import { trpc } from '@/client' const CreateUserForm = () => { const createUser = trpc.createUser.useMutation() const handleSubmit = () => { // 引数の型が自動で { name: string; email: string } になる createUser.mutate({ name: 'Bob', email: 'bob@example.com' }) } return <button onClick={handleSubmit}>作成</button> }
型がどう共有されるか
サーバー(router)
└─ Zod スキーマで input/output を定義
└─ AppRouter 型としてエクスポート
└─ クライアントに型だけ渡す(コードは渡さない)
└─ useQuery / useMutation で型補完が効く
実行時にAPIを叩くのは HTTP ですが、TypeScript の型レベルでは直接繋がっているように見えます。
バリデーションエラーはクライアントに伝わる
// サーバー側スキーマ
.input(z.object({ email: z.string().email() }))
// クライアント側
createUser.mutate({ email: 'invalid' })
// → TRPCError として返ってくる
// → error.data?.zodError でフィールドごとのエラーが取れる
if (createUser.error?.data?.zodError) {
const fieldErrors = createUser.error.data.zodError.fieldErrors
console.log(fieldErrors.email) // ['Invalid email']
}
まとめ
| やること | コード |
|---|---|
| router の定義 | t.router({ ... }) |
| 入力スキーマ | .input(z.object({ ... })) |
| query(取得) | .query(({ input }) => ...) |
| mutation(変更) | .mutation(({ input }) => ...) |
| 型のエクスポート | export type AppRouter = typeof appRouter |
| クライアント生成 | createTRPCReact<AppRouter>() |
Zod の基本的なスキーマ定義については「Zod 入門:z.object・z.infer・safeParse の使い方」を参照してください。