小さなtRPCクライアントを作成する
このページは PageTurner AI で翻訳されました(ベータ版)。プロジェクト公式の承認はありません。 エラーを見つけましたか? 問題を報告 →
tRPCがどのように動作するか考えたことはありますか?プロジェクトへの貢献を始めたいけれど、内部実装に怖気づいていませんか?この記事では、tRPCの主要な仕組みをカバーする最小限のクライアントを作成することで、tRPCの内部実装に親しむことを目的としています。
ジェネリクス、条件型、extendsキーワード、再帰型など、TypeScriptの核心概念を理解しておくことを推奨します。これらの概念に慣れていない場合は、Matt PocockのBeginner TypeScriptチュートリアルで学んでから読み進めることをおすすめします。
概要
次のような3つのプロシージャを持つシンプルなtRPCルーターがあると仮定しましょう:
tstypePost = {id : string;title : string };constposts :Post [] = [];constappRouter =router ({post :router ({byId :publicProcedure .input (z .object ({id :z .string () })).query (({input }) => {constpost =posts .find ((p ) =>p .id ===input .id );if (!post ) throw newTRPCError ({code : "NOT_FOUND" });returnpost ;}),byTitle :publicProcedure .input (z .object ({title :z .string () })).query (({input }) => {constpost =posts .find ((p ) =>p .title ===input .title );if (!post ) throw newTRPCError ({code : "NOT_FOUND" });returnpost ;}),create :publicProcedure .input (z .object ({title :z .string () })).mutation (({input }) => {constpost = {id :uuid (), ...input };posts .push (post );returnpost ;}),}),});
tstypePost = {id : string;title : string };constposts :Post [] = [];constappRouter =router ({post :router ({byId :publicProcedure .input (z .object ({id :z .string () })).query (({input }) => {constpost =posts .find ((p ) =>p .id ===input .id );if (!post ) throw newTRPCError ({code : "NOT_FOUND" });returnpost ;}),byTitle :publicProcedure .input (z .object ({title :z .string () })).query (({input }) => {constpost =posts .find ((p ) =>p .title ===input .title );if (!post ) throw newTRPCError ({code : "NOT_FOUND" });returnpost ;}),create :publicProcedure .input (z .object ({title :z .string () })).mutation (({input }) => {constpost = {id :uuid (), ...input };posts .push (post );returnpost ;}),}),});
クライアントの目標は、このオブジェクト構造をクライアント側で再現し、次のようにプロシージャを呼び出せるようにすることです:
tsconst post1 = await client.post.byId.query({ id: '123' });const post2 = await client.post.byTitle.query({ title: 'Hello world' });const newPost = await client.post.create.mutate({ title: 'Foo' });
tsconst post1 = await client.post.byId.query({ id: '123' });const post2 = await client.post.byTitle.query({ title: 'Hello world' });const newPost = await client.post.create.mutate({ title: 'Foo' });
これを実現するため、tRPCはProxyオブジェクトとTypeScriptのマジックを組み合わせて、オブジェクト構造に.queryや.mutateメソッドを追加します。つまり、優れた開発者体験を提供するために、実際には(後述しますが)操作の実態について「嘘」をついているのです!
高レベルで言えば、post.byId.query()をサーバーへのGETリクエストに、post.create.mutate()をPOSTリクエストにマッピングし、型情報がバックエンドからフロントエンドに正しく伝播されるようにしたいわけです。では、どう実装すればよいでしょうか?
小さなtRPCクライアントの実装
🧙♀️ TypeScriptのマジック
まずは、tRPC使用時に皆が愛用している素晴らしいオートコンプリートと型安全性を実現する楽しいTypeScriptのマジックから始めましょう。
任意の深さのルーター構造を推論するために再帰型を使用する必要があります。また、post.byIdやpost.createといったプロシージャにそれぞれ.queryや.mutateメソッドを持たせたいと考えています。tRPCではこれをプロシージャの装飾(decorating)と呼びます。@trpc/serverには、解決済みメソッドを持つプロシージャの入力/出力型を推論するヘルパーがあるので、これらを使用して関数の型を推論するコードを書きましょう。
パスのオートコンプリートとプロシージャの入力/出力型推論を実現するために必要なことを考えてみましょう:
-
ルーター上では、そのサブルーターやプロシージャにアクセス可能であること(これについては後ほど説明します)
-
クエリプロシージャ上では、
.queryを呼び出せること -
ミューテーションプロシージャ上では、
.mutateを呼び出せること -
それ以外のものにアクセスしようとした場合、バックエンドに存在しないプロシージャであることを示す型エラーが発生すること
これらを実現する型を作成しましょう:
tstypeDecorateProcedure <TProcedure > =TProcedure extendsAnyTRPCQueryProcedure ? {query :Resolver <TProcedure >;}:TProcedure extendsAnyTRPCMutationProcedure ? {mutate :Resolver <TProcedure >;}: never;
tstypeDecorateProcedure <TProcedure > =TProcedure extendsAnyTRPCQueryProcedure ? {query :Resolver <TProcedure >;}:TProcedure extendsAnyTRPCMutationProcedure ? {mutate :Resolver <TProcedure >;}: never;
tRPC組み込みの推論ヘルパーの一部を使用して、プロシージャの入力/出力型を推論し、Resolver型を定義します。
tsimport type {AnyTRPCProcedure ,inferProcedureInput ,inferProcedureOutput ,AnyTRPCQueryProcedure ,AnyTRPCMutationProcedure } from '@trpc/server';typeResolver <TProcedure extendsAnyTRPCProcedure > = (input :inferProcedureInput <TProcedure >,) =>Promise <inferProcedureOutput <TProcedure >>;
tsimport type {AnyTRPCProcedure ,inferProcedureInput ,inferProcedureOutput ,AnyTRPCQueryProcedure ,AnyTRPCMutationProcedure } from '@trpc/server';typeResolver <TProcedure extendsAnyTRPCProcedure > = (input :inferProcedureInput <TProcedure >,) =>Promise <inferProcedureOutput <TProcedure >>;
post.byIdプロシージャで試してみましょう:
tstypePostById =Resolver <AppRouter ['post']['byId']>;
tstypePostById =Resolver <AppRouter ['post']['byId']>;
素晴らしい、期待通りです!プロシージャで.queryを呼び出し、正しい入力/出力型が推論されるようになりました。
最後に、ルーターを再帰的に走査し、通過する全てのプロシージャを装飾する型を作成します:
tsimport type {TRPCRouterRecord } from "@trpc/server";import type {AnyTRPCRouter } from "@trpc/server";typeDecorateRouterRecord <TRecord extendsTRPCRouterRecord > = {[TKey in keyofTRecord ]:TRecord [TKey ] extends infer$Value ?$Value extendsTRPCRouterRecord ?DecorateRouterRecord <$Value >:$Value extendsAnyTRPCProcedure ?DecorateProcedure <$Value >: never: never;};
tsimport type {TRPCRouterRecord } from "@trpc/server";import type {AnyTRPCRouter } from "@trpc/server";typeDecorateRouterRecord <TRecord extendsTRPCRouterRecord > = {[TKey in keyofTRecord ]:TRecord [TKey ] extends infer$Value ?$Value extendsTRPCRouterRecord ?DecorateRouterRecord <$Value >:$Value extendsAnyTRPCProcedure ?DecorateProcedure <$Value >: never: never;};
この型を少し解釈してみましょう:
-
ジェネリックとして
TRPCRouterRecordを型に渡します。これはtRPCルーター上に存在する全てのプロシージャとサブルーターを含む型です。 -
レコードのキー(手続きまたはルーター名)を反復処理し、以下を実行します:
- キーがルーターを指す場合、そのルーターの手続きレコードに対して再帰的にこの型を適用し、そのルーター内のすべての手続きを装飾します。これによりパスをたどる際のオートコンプリートが提供されます。
- キーが手続きを指す場合、先ほど作成した
DecorateProcedure型を使用して手続きを装飾します。 - キーが手続きもルーターも指さない場合、
never型を割り当てます。これは「このキーは存在しない」ことを意味し、アクセスしようとすると型エラーが発生します。
🤯 Proxyを使ったリマッピング
型定義が整ったので、クライアント上でサーバーのルーター定義を拡張し、手続きを通常の関数のように呼び出せる機能を実際に実装します。
まず再帰的プロキシを作成するヘルパー関数 createRecursiveProxy を作成します:
これはエッジケースの処理を除けば本番環境で使われている実装とほぼ同じです。実際のコードを参照してください!
tsinterfaceProxyCallbackOptions {path : readonly string[];args : readonly unknown[];}typeProxyCallback = (opts :ProxyCallbackOptions ) => unknown;functioncreateRecursiveProxy (callback :ProxyCallback ,path : readonly string[]) {constproxy : unknown = newProxy (() => {// dummy no-op function since we don't have any// client-side target we want to remap to},{get (_obj ,key ) {if (typeofkey !== 'string') returnundefined ;// Recursively compose the full path until a function is invokedreturncreateRecursiveProxy (callback , [...path ,key ]);},apply (_1 ,_2 ,args ) {// Call the callback function with the entire path we// recursively created and forward the argumentsreturncallback ({path ,args ,});},},);returnproxy ;}
tsinterfaceProxyCallbackOptions {path : readonly string[];args : readonly unknown[];}typeProxyCallback = (opts :ProxyCallbackOptions ) => unknown;functioncreateRecursiveProxy (callback :ProxyCallback ,path : readonly string[]) {constproxy : unknown = newProxy (() => {// dummy no-op function since we don't have any// client-side target we want to remap to},{get (_obj ,key ) {if (typeofkey !== 'string') returnundefined ;// Recursively compose the full path until a function is invokedreturncreateRecursiveProxy (callback , [...path ,key ]);},apply (_1 ,_2 ,args ) {// Call the callback function with the entire path we// recursively created and forward the argumentsreturncallback ({path ,args ,});},},);returnproxy ;}
少し魔法のように見えますが、これは何をするのでしょうか?
-
getメソッドはpost.byIdのようなプロパティアクセスを処理します。キーはアクセスするプロパティ名で、例えばpostと入力するとkeyはpostに、post.byIdと入力するとkeyはbyIdになります。再帰的プロキシはこれらすべてのキーを最終的なパス(例:["post", "byId", "query"])に結合し、リクエストを送信するURLを決定するために使用します。 -
applyメソッドは.query(args)のようにプロキシ上で関数を呼び出す際に実行されます。argsは関数に渡す引数で、例えばpost.byId.query(args)を呼び出すとargsは入力値となり、手続きのタイプに応じてクエリパラメータまたはリクエストボディとして提供されます。createRecursiveProxyはコールバック関数を受け取り、applyをパスと引数でマッピングします。
以下は trpc.post.byId.query({ id: 1 }) の呼び出しにおけるプロキシの動作を視覚化したものです:

🧩 すべてを組み合わせる
このヘルパーとその動作が理解できたので、クライアント作成に使用しましょう。createRecursiveProxy にパスと引数を受け取り fetch を使ってサーバーにリクエストするコールバックを提供します。関数には任意のtRPCルーター型(AnyTRPCRouter)を受け入れるジェネリックを追加し、戻り型を先ほど作成した DecorateRouterRecord 型にキャストします:
tsimport {TRPCResponse } from '@trpc/server/rpc';export constcreateTinyRPCClient = <TRouter extendsAnyTRPCRouter >(baseUrl : string,) =>createRecursiveProxy (async (opts ) => {constpath = [...opts .path ]; // e.g. ["post", "byId", "query"]constmethod =path .pop ()! as 'query' | 'mutate';constdotPath =path .join ('.'); // "post.byId" - this is the path procedures have on the backendleturi = `${baseUrl }/${dotPath }`;const [input ] =opts .args ;conststringifiedInput =input !==undefined &&JSON .stringify (input );letbody : undefined | string =undefined ;if (stringifiedInput !== false) {if (method === 'query') {uri += `?input=${encodeURIComponent (stringifiedInput )}`;} else {body =stringifiedInput ;}}constjson :TRPCResponse = awaitfetch (uri , {method :method === 'query' ? 'GET' : 'POST',headers : {'Content-Type': 'application/json',},body ,}).then ((res ) =>res .json ());if ('error' injson ) {throw newError (`Error: ${json .error .message }`);}// No error - all good. Return the data.returnjson .result .data ;}, []) asDecorateRouterRecord <TRouter ['_def']['record']>;// ^? provide empty array as path to begin with
tsimport {TRPCResponse } from '@trpc/server/rpc';export constcreateTinyRPCClient = <TRouter extendsAnyTRPCRouter >(baseUrl : string,) =>createRecursiveProxy (async (opts ) => {constpath = [...opts .path ]; // e.g. ["post", "byId", "query"]constmethod =path .pop ()! as 'query' | 'mutate';constdotPath =path .join ('.'); // "post.byId" - this is the path procedures have on the backendleturi = `${baseUrl }/${dotPath }`;const [input ] =opts .args ;conststringifiedInput =input !==undefined &&JSON .stringify (input );letbody : undefined | string =undefined ;if (stringifiedInput !== false) {if (method === 'query') {uri += `?input=${encodeURIComponent (stringifiedInput )}`;} else {body =stringifiedInput ;}}constjson :TRPCResponse = awaitfetch (uri , {method :method === 'query' ? 'GET' : 'POST',headers : {'Content-Type': 'application/json',},body ,}).then ((res ) =>res .json ());if ('error' injson ) {throw newError (`Error: ${json .error .message }`);}// No error - all good. Return the data.returnjson .result .data ;}, []) asDecorateRouterRecord <TRouter ['_def']['record']>;// ^? provide empty array as path to begin with
特に注目すべきは、パスが / ではなく . で区切られている点です。これによりサーバー側で各手続きごとに個別のハンドラーを作成するのではなく、単一のAPIハンドラーですべてのリクエストを処理できます。Next.jsのようなファイルベースルーティングを使用するフレームワークでは、すべての手続きパスにマッチするキャッチオールファイル /api/trpc/[trpc].ts を認識するかもしれません。
また fetch リクエストには TRPCResponse 型の注釈が付いています。これはサーバーが応答するJSONRPC準拠のレスポンス形式を定義します。詳細はこちらで読めます。要約すると、成功時は result オブジェクトが、エラー時は error オブジェクトが返り、これを使ってリクエストの成否を判断し適切なエラー処理が行えます。
これで完了です!このコードだけで、tRPCの手続きをあたかもローカル関数のようにクライアントから呼び出せます。表面的には単に publicProcedure.query / mutation のリゾルバー関数を通常のプロパティアクセスで呼んでいるように見えますが、実際にはネットワーク境界を越えており、Prismaのようなサーバーサイドライブラリをデータベース認証情報を漏らさずに使用できます。
試してみよう!
クライアントを作成し、サーバーのURLを指定すれば、手続きを呼び出す際に完全なオートコンプリートと型安全性が得られます!
tsconsturl = 'http://localhost:3000/api/trpc';constclient =createTinyRPCClient <AppRouter >(url );// 🧙♀️ magic autocompletionclient .post .b ;//// 👀 fully typesafeconstpost = awaitclient .post .byId .query ({id : '123' });
tsconsturl = 'http://localhost:3000/api/trpc';constclient =createTinyRPCClient <AppRouter >(url );// 🧙♀️ magic autocompletionclient .post .b ;//// 👀 fully typesafeconstpost = awaitclient .post .byId .query ({id : '123' });
クライアントの完全なコードはこちらで、使用方法を示すテストはこちらで確認できます。
まとめ
この記事が楽しめ、tRPCの仕組みについて学びが得られたなら幸いです。ただし今回作成したクライアントではなく、ほんの数KB大きいだけの@trpc/clientを使用することをお勧めします。公式クライアントにはここで紹介した機能以上の柔軟性が備わっています:
-
中止シグナルやSSR対応などのクエリオプション
-
リンク機能
-
手続きのバッチ処理
-
WebSocket/サブスクリプション対応
-
洗練されたエラーハンドリング
-
データ変換機能
-
tRPC準拠でないレスポンスへのエッジケース対応
サーバー側の詳細については今回は深く触れませんでしたが、今後の記事で取り上げるかもしれません。質問があれば、Twitterでお気軽に連絡ください。
