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;};
この型を少し詳しく見てみましょう。
- ジェネリックとして、tRPCルーター上に存在するすべてのプロシージャとサブルーターを含む型である
TRPCRouterRecord
を型に渡します。 - レコードのキー(プロシージャ名またはルーター名)を反復処理し、次の操作を行います。
- キーがルーターにマップされている場合、そのルーターのプロシージャレコードに対して型を再帰的に呼び出します。これにより、パスをトラバースする際にオートコンプリートが提供されます。
- キーがプロシージャにマップされている場合、前に作成した
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 })
呼び出しでプロキシがどのように機能するかを示す図です。
🧩 すべてをまとめる
このヘルパーが作成され、その機能がわかったところで、それを使用してクライアントを作成しましょう。createRecursiveProxy
に、パスと引数を受け取り、fetch
を使用してサーバーにリクエストするコールバックを提供します。任意の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準拠のレスポンス形式を決定します。これについてはこちらで詳しく読むことができます。TL;DR: 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の仕組みについて何かを学んでいただければ幸いです。@trpc/clientの方が数KBしか大きくなく、こちらで紹介したものよりもはるかに柔軟性が高いので、これを使用することはお勧めしません。
- 中断シグナル、SSRなどのクエリオプション…
- リンク
- プロシージャのバッチ処理
- WebSockets / サブスクリプション
- 優れたエラー処理
- データトランスフォーマー
- tRPC準拠のレスポンスが得られない場合などのエッジケース処理
今日はサーバー側の内容についてはあまり触れませんでしたが、今後の記事で取り上げるかもしれません。ご質問があれば、Twitterで気軽にご連絡ください。