
tRPCを触ってみる
2023.06.12
気になっていたtRPCを実際に触ってみた記録です!
tRPCとは
公式から引用させてください。
tRPC allows you to easily build & consume fully typesafe APIs without schemas or code generation.
引用: https://trpc.io/docs/#introduction
この通りで、スキーマ等なしで、簡単に型安全なAPIを作成することができます!
ちなみに、tRPCは、TypeScriptを想定して作られているので、TypeScript以外での使用はできません><
逆に、TypeScriptを使ったプロジェクトでは、かなり有用なツールになり得ます。
サンプルアプリを構築してみる
今回はNext.jsを使って、tRPCのサンプルアプリを作ってみようと思います。
Next.jsのAPI Routesを利用して、tRPCの制限をかけたAPIを作成し、クライアント側でそのAPIを呼び出す、という形を想定しています。
また、ディレクトリ構成は、公式がおすすめしていた以下の構成をとるようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | // 引用: https://trpc.io/docs/nextjs/setup#recommended-file-structure . ├── prisma # <-- if prisma is added │ └── [..] ├── src │ ├── pages │ │ ├── _app.tsx # <-- add `withTRPC()`-HOC here │ │ ├── api │ │ │ └── trpc │ │ │ └── [trpc].ts # <-- tRPC HTTP handler │ │ └── [..] │ ├── server │ │ ├── routers │ │ │ ├── _app.ts # <-- main app router │ │ │ ├── post.ts # <-- sub routers │ │ │ └── [..] │ │ ├── context.ts # <-- create app context │ │ └── trpc.ts # <-- procedure helpers │ └── utils │ └── trpc.ts # <-- your typesafe tRPC hooks └── [..] |
Next.jsをインストール
1 | yarn create next-app --typescript |
tRPCのモジュールをインストール
1 | npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zod |
サーバー側のtRPCのセットアップ
APIを提供するサーバー側のセットアップを行なっていきます。
tRPCのインスタンス生成
tRPCを使用する上で必要なものを用意します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | // /src/server/trpc.ts import { initTRPC, TRPCError } from '@trpc/server'; // initTRPCはアプリ内で一度のみ使用する const t = initTRPC.create({ errorFormatter({ shape }) { return shape; }, }); export const router = t.router; // API毎のプロシージャのベースとなるプロシージャ。認証別など、複数のベースプロシージャを定義できる export const publicProcedure = t.procedure; // tRPC上のミドルウェア export const middleware = t.middleware; // 全てのプロシージャをフラットに1つの名前空間にする場合に使用する export const mergeRouters = t.mergeRouters; |
ルーターを作成する
疎通確認のためのAPI、healthcheckのルートを追加してます。
1 2 3 4 5 6 7 8 | // /src/server/routers/_app.ts import { publicProcedure, router } from "../trpc"; export const appRouter = router({ healthcheck: publicProcedure.query(() => 'fine!') }); export type AppRouter = typeof appRouter; |
API/APIハンドラーを追加
今回は、Next.js API Routes対象の/api/trpc/[trpc]
にエンドポイントを置きます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | // src/pages/api/trpc/[trpc].ts import * as trpcNext from '@trpc/server/adapters/next'; import { appRouter } from '@/server/routers/_app'; export default trpcNext.createNextApiHandler({ router: appRouter, onError({ error }) { if (error.code === 'INTERNAL_SERVER_ERROR') { console.error('Something went wrong', error); } }, batching: { enabled: true, }, }); |
APIにアクセスしてみる
この時点で、http://localhost:3000/api/trpc/healthcheck にアクセスすると、以下のレスポンスが返ってきました!
1 2 3 4 5 | { "result": { "data": "fine!" } } |
Contextを追加してみる
tRPCにはContextという機能があり、全てのtRPCプロシージャがアクセス可能なデータを格納できます。
認証情報の共有などに便利ですが、今回はCookieを共有させてみます。
1. Contextの型定義&生成処理を作成する
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | // /src/server/routers/context.ts import * as trpc from '@trpc/server'; import * as trpcNext from '@trpc/server/adapters/next'; interface CreateContextOptions { cookie: Partial<{ [key: string]: string; }>, } // context作成時の内部処理を切り出すことで、テスト時に便利になるそう export async function createContextInner(opts: CreateContextOptions) { return { cookie: opts.cookie }; } export type Context = trpc.inferAsyncReturnType<typeof createContextInner>; export async function createContext( opts: trpcNext.CreateNextContextOptions, ): Promise<Context> { return await createContextInner({ cookie: opts.req.cookies }); } |
2. initTRPCに連携する
1 2 3 4 5 6 7 8 9 10 11 | // /src/server/trpc.ts // ... import { Context } from './context'; const t = initTRPC.context<Context>().create({ errorFormatter({ shape }) { return shape; }, }); // ... |
3. 試しに取得してみる
ここではmiddleware内で取得していますが、プロシージャ内でも取得できます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | // /src/server/trpc.ts // ... const isAuthed = t.middleware(({ next, ctx }) => { // contextからcookieを取り出す if (!ctx.cookie['test-cookie']) { throw new TRPCError({ code: 'UNAUTHORIZED', }); } return next({ ctx: { cookie: ctx.cookie }, }); }); export const protectedProcedure = t.procedure.use(isAuthed) // このベースプロシージャを適用すると、isAuthedがtrueの場合のアクセスのみ許可できる // ... |
クライアント側のtRPCのセットアップ
_app.tsxの変更
tRPCの高階コンポーネントをAppに適用します。
1 2 3 4 5 6 7 8 9 | import '@/styles/globals.css' import { trpc } from '@/utils/trpc' import type { AppProps } from 'next/app' function App({ Component, pageProps }: AppProps) { return <Component {...pageProps} /> } export default trpc.withTRPC(App) |
tRPCのhookを用意する
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | // /src/utils/trpc.ts import type { AppRouter } from '@/server/routers/_app'; import { httpBatchLink } from '@trpc/client'; import { createTRPCNext } from '@trpc/next'; function getBaseUrl() { if (typeof window !== 'undefined') // ブラウザでは相対パスを利用 return ''; return `http://localhost:${process.env.PORT ?? 3000}`; } export const trpc = createTRPCNext<AppRouter>({ config() { return { links: [ httpBatchLink({ url: `${getBaseUrl()}/api/trpc`, // ヘッダー操作などはここでできる // async headers() { // return { // authorization: 'token', // }; // }, }), ], }; }, ssr: typeof window === 'undefined', }); |
APIリクエストしてみる
先ほど確認用のルート(healthcheck)を追加したので、クライアントから呼び出してみます。
1 2 3 4 5 6 7 8 9 10 11 12 | // /src/pages/index.tsx import { trpc } from '@/utils/trpc' export default function Home() { const { data, isLoading} = trpc.healthcheck.useQuery() return ( <> {isLoading && 'loading...'} {data} </> ) } |
http://localhost:3000/ にアクセスしてみると、無事に取得したデータが表示されていました!
セットアップ完了
これでクライアントと、tRPCベースのAPIの疎通もできました!
意外と簡単に構築でき、びっくりです。
APIのinputの型付け
tRPCといえば、APIの型付けができる点が魅力ポイントなので、やってみます!
サーバー側のルート追加
サブルーターを作る
今回追加するルートに当たるサブルーターを用意します。
今回はお試しなので、数字を受け取って、その数字の分だけ1
を返すAPIを作ります。
inputとしては、1~50までのnumber
値 count
を受け取ります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | // /src/server/routers/sample.ts import { router, publicProcedure } from '../trpc'; import { z } from 'zod'; export const sampleRouter = router({ repeat: publicProcedure // ここでinputの型指定 .input( z.object({ count: z.number().min(1).max(50), }), ) .query(async ({ input }) => { const items = [] for (let i = 0; i < input.count; i++) { items.push('1') } return { items } }), }); |
今回はバリデーションライブラリとして、Zodを使用していますが、YupやSuperstructも使用できます。
サブルーターを全体ルーターに組み込み
作成したサブルーターを全体ルーターに組み込みます。
1 2 3 4 5 6 7 8 9 10 11 | // /src/server/routers/_app.ts import { protectedProcedure, publicProcedure, router } from "../trpc"; import { sampleRouter } from "./sample"; export const appRouter = router({ healthcheck: publicProcedure.query(() => 'fine!'), sample: sampleRouter // 追加 }); // Export also type router type signature, export type AppRouter = typeof appRouter; |
クライアント側で呼び出す
APIリクエストする
クライアント側から呼び出します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | // /src/pages/index.tsx import { trpc } from '@/utils/trpc' export default function Home() { const { data: healthCheckData} = trpc.healthcheck.useQuery() const { data: repeatData } = trpc.sample.repeat.useQuery({ count: 20 }) // 追加 return ( <> {healthCheckData} {repeatData?.items.map((item) => item)} </> ) } |
通信を確認してみる
今回、最初に作ったヘルスチェックのクエリと、新しく作ったリピートクエリを2つ呼び出していましたが、エンドポイントも1つになって呼び出されています!
ちなみに、複数のAPIがまとめてコールされているのは、httpBatchLinkやbatchingの設定をバッチ処理するように指定しているからです。
もちろん、個別に実行したい場合は、設定を変更すればOKです。
指定したinputの型以外を送信してみる
1~50の範囲外の値をinputに入れてみました。
結果は、エラー(400 Bad Request)となり、適合しないinputでは通信が失敗するようになっています!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | // response [ { error: { message: '[\n {\n "code": "too_big",\n "maximum": 50,\n "type": "number",\n "inclusive": true,\n "exact": false,\n "message": "Number must be less than or equal to 50",\n "path": [\n "count"\n ]\n }\n]', code: -32600, data: { code: 'BAD_REQUEST', httpStatus: 400, stack: 'TRPCError: ...', path: 'sample.repeat', }, }, }, ] |
outputの型付け
レスポンスに当たるoutputの型付けも以下のように簡単にできます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | // /src/server/routers/sample.ts import { router, publicProcedure } from '../trpc'; import { z } from 'zod'; export const sampleRouter = router({ repeat: publicProcedure .input( z.object({ count: z.number().min(1).max(50), }), ) // アウトプットのバリデーション追加 .output( z.object({ items: z.string().array(), }), ) .query(async ({ input }) => { const items = [] for (let i = 0; i < input.count; i++) { items.push('1') } return { items } }), }), |
ちなみに、アウトプットのバリデーションに引っかかった場合は、デフォルトでは以下のようなレスポンスが返ってきました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | // response [ { error: { message: 'Output validation failed', code: -32603, data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500, stack: 'TRPCError: Output validation failed ...', path: 'sample.repeat', }, }, }, ] |
まとめ
今回は、tRPCの基本的な部分を触ってみました!
今回はNext.jsでしたが、他の技術でも利用可能です。公式でボイラープレートやアダプターなど公開されているので、ぜひチェックしてみてください。
→ https://trpc.io/docs/awesome-trpc
書いた人はこんな人

- Webエンジニア。
好きなものはフルーツタルト。
IT技術10月 25, 2023Renovateのおすすめ設定
IT技術4月 10, 2023tRPCを触ってみる
IT技術1月 10, 2023Webアクセシビリティとエンジニア
IT技術10月 14, 2022知ってると便利なCSS 8選!