tRPC がどのように動作するのか疑問に思ったことはありませんか?もしかしたら、プロジェクトへの貢献を始めたいけれど、内部構造に恐れを抱いているかもしれませんか?この投稿の目的は、tRPC の動作の大きな部分をカバーする最小限のクライアントを作成することで、tRPC の内部構造に慣れてもらうことです。
ジェネリクス、条件型、extends
キーワード、再帰など、TypeScript のコアコンセプトを理解していることをお勧めします。これらに馴染みがない場合は、Matt Pocock 氏の Beginner TypeScript チュートリアルを読んで、これらの概念に慣れてから読み進めることをお勧めします。
概要
次のような3つのプロシージャを持つシンプルな tRPC ルーターがあると仮定しましょう。
ts
typePost = {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 ;}),}),});
ts
typePost = {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 ;}),}),});
クライアントの目標は、クライアント上でこのオブジェクト構造を模倣して、次のようにプロシージャを呼び出せるようにすることです。
ts
const 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' });
ts
const 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 では、これをプロシージャのデコレーションと呼びます。@trpc/server
では、これらの解決されたメソッドでプロシージャの入力型と出力型を推論するいくつかの推論ヘルパーがあり、これを使用してこれらの関数の型を推論するので、コードを書いてみましょう!
パスのオートコンプリートとプロシージャの入力型と出力型の推論を提供するために、何を達成したいのかを考えてみましょう。
- ルーターにいる場合、そのサブルーターとプロシージャにアクセスできるようにする必要があります。(これについては後ほど説明します)
- クエリプロシージャにいる場合は、そのプロシージャで
.query
を呼び出せるようにする必要があります。 - ミューテーションプロシージャにいる場合は、そのプロシージャで
.mutate
を呼び出せるようにする必要があります。 - それ以外のものにアクセスしようとすると、バックエンドにそのプロシージャが存在しないことを示す型エラーが表示されるようにする必要があります。
それでは、これを実現する型を作成しましょう。
ts
typeDecorateProcedure <TProcedure > =TProcedure extendsAnyTRPCQueryProcedure ? {query :Resolver <TProcedure >;}:TProcedure extendsAnyTRPCMutationProcedure ? {mutate :Resolver <TProcedure >;}: never;
ts
typeDecorateProcedure <TProcedure > =TProcedure extendsAnyTRPCQueryProcedure ? {query :Resolver <TProcedure >;}:TProcedure extendsAnyTRPCMutationProcedure ? {mutate :Resolver <TProcedure >;}: never;
tRPC の組み込み推論ヘルパーを使用して、プロシージャの入力型と出力型を推論し、Resolver
型を定義します。
ts
import type {AnyTRPCProcedure ,inferProcedureInput ,inferProcedureOutput ,AnyTRPCQueryProcedure ,AnyTRPCMutationProcedure } from '@trpc/server';typeResolver <TProcedure extendsAnyTRPCProcedure > = (input :inferProcedureInput <TProcedure >,) =>Promise <inferProcedureOutput <TProcedure >>;
ts
import type {AnyTRPCProcedure ,inferProcedureInput ,inferProcedureOutput ,AnyTRPCQueryProcedure ,AnyTRPCMutationProcedure } from '@trpc/server';typeResolver <TProcedure extendsAnyTRPCProcedure > = (input :inferProcedureInput <TProcedure >,) =>Promise <inferProcedureOutput <TProcedure >>;
post.byId
プロシージャでこれを試してみましょう。
ts
typePostById =Resolver <AppRouter ['post']['byId']>;
ts
typePostById =Resolver <AppRouter ['post']['byId']>;
いいですね、期待どおりです。これで、プロシージャで .query
を呼び出して、正しい入力型と出力型を推論できるようになりました!
最後に、ルーターを再帰的にトラバースし、途中のすべてのプロシージャをデコレートする型を作成します。
ts
import 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;};
ts
import 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
を作成します。
これは、いくつかのエッジケースを処理していないことを除けば、本番環境で使用されているほぼ正確な実装です。 ご自身でご確認ください!
ts
interfaceProxyCallbackOptions {path : string[];args : unknown[];}typeProxyCallback = (opts :ProxyCallbackOptions ) => unknown;functioncreateRecursiveProxy (callback :ProxyCallback ,path : 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 ;}
ts
interfaceProxyCallbackOptions {path : string[];args : unknown[];}typeProxyCallback = (opts :ProxyCallbackOptions ) => unknown;functioncreateRecursiveProxy (callback :ProxyCallback ,path : 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 })
の呼び出しにおけるプロキシの動作方法を視覚的に表したものです。
🧩 すべてをまとめる
このヘルパーがあり、その動作がわかったので、それを使用してクライアントを作成しましょう。パスと引数を受け取り、fetch
を使用してサーバーにリクエストするコールバックを createRecursiveProxy
に提供します。任意の tRPC ルーター型 (AnyTRPCRouter
) を受け入れるジェネリックを関数に追加し、戻り型を前に作成した DecorateRouterRecord
型にキャストします。
ts
import {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
ts
import {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 ハンドラーを持ち、すべてのリクエストを処理できます。プロシージャごとに 1 つのハンドラーを持つ必要はありません。Next.js のようなファイルベースのルーティングを持つフレームワークを使用している場合は、すべてのプロシージャパスに一致するキャッチオール /api/trpc/[trpc].ts
ファイルを認識するかもしれません。
また、fetch
リクエストに TRPCResponse
型アノテーションがあります。これは、サーバーが応答する JSONRPC 準拠の応答形式を決定します。詳細については、こちらを参照してください。簡単に言うと、result
オブジェクトまたは error
オブジェクトのいずれかが返されます。これを使用して、リクエストが成功したかどうかを判断し、何か問題が発生した場合に適切なエラー処理を行うことができます。
これで完了です!これは、クライアントで tRPC プロシージャをローカル関数であるかのように呼び出すために必要なすべてのコードです。表面上は、通常のプロパティアクセスを介して publicProcedure.query / mutation
のリゾルバー関数を呼び出しているように見えますが、実際にはネットワーク境界を越えているため、データベースクレデンシャルをリークすることなく、Prisma のようなサーバーサイドライブラリを使用できます。
試してみる!
それでは、クライアントを作成し、サーバーの URL を指定すると、プロシージャを呼び出すときに完全なオートコンプリートと型安全性が得られます!
ts
consturl = 'http://localhost:3000/api/trpc';constclient =createTinyRPCClient <AppRouter >(url );// 🧙♀️ magic autocompletionclient .post .b ;//// 👀 fully typesafeconstpost = awaitclient .post .byId .query ({id : '123' });
ts
consturl = '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 などのクエリオプション...
- リンク
- プロシージャバッチ処理
- WebSockets / サブスクリプション
- 優れたエラー処理
- データトランスフォーマー
- tRPC 準拠の応答が得られない場合などのエッジケースの処理
また、今日はサーバーサイドのことについてはあまり触れませんでした。おそらく今後の記事でそれについて説明します。ご質問があれば、Twitter で遠慮なく私にご連絡ください。