メインコンテンツへスキップ

小さなtRPCクライアントを作成する

· 1分で読める
Julius Marminge
tRPC Core Team Member
非公式ベータ版翻訳

このページは PageTurner AI で翻訳されました(ベータ版)。プロジェクト公式の承認はありません。 エラーを見つけましたか? 問題を報告 →

tRPCがどのように動作するか考えたことはありますか?プロジェクトへの貢献を始めたいけれど、内部実装に怖気づいていませんか?この記事では、tRPCの主要な仕組みをカバーする最小限のクライアントを作成することで、tRPCの内部実装に親しむことを目的としています。

情報

ジェネリクス、条件型、extendsキーワード、再帰型など、TypeScriptの核心概念を理解しておくことを推奨します。これらの概念に慣れていない場合は、Matt PocockBeginner TypeScriptチュートリアルで学んでから読み進めることをおすすめします。

概要

次のような3つのプロシージャを持つシンプルなtRPCルーターがあると仮定しましょう:

ts
type Post = { id: string; title: string };
const posts: Post[] = [];
 
const appRouter = router({
post: router({
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const post = posts.find((p) => p.id === input.id);
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
byTitle: publicProcedure
.input(z.object({ title: z.string() }))
.query(({ input }) => {
const post = posts.find((p) => p.title === input.title);
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
create: publicProcedure
.input(z.object({ title: z.string() }))
.mutation(({ input }) => {
const post = { id: uuid(), ...input };
posts.push(post);
return post;
}),
}),
});
ts
type Post = { id: string; title: string };
const posts: Post[] = [];
 
const appRouter = router({
post: router({
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const post = posts.find((p) => p.id === input.id);
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
byTitle: publicProcedure
.input(z.object({ title: z.string() }))
.query(({ input }) => {
const post = posts.find((p) => p.title === input.title);
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
create: publicProcedure
.input(z.object({ title: z.string() }))
.mutation(({ input }) => {
const post = { id: uuid(), ...input };
posts.push(post);
return post;
}),
}),
});

クライアントの目標は、このオブジェクト構造をクライアント側で再現し、次のようにプロシージャを呼び出せるようにすることです:

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.byIdpost.createといったプロシージャにそれぞれ.query.mutateメソッドを持たせたいと考えています。tRPCではこれをプロシージャの装飾(decorating)と呼びます。@trpc/serverには、解決済みメソッドを持つプロシージャの入力/出力型を推論するヘルパーがあるので、これらを使用して関数の型を推論するコードを書きましょう。

パスのオートコンプリートとプロシージャの入力/出力型推論を実現するために必要なことを考えてみましょう:

  • ルーター上では、そのサブルーターやプロシージャにアクセス可能であること(これについては後ほど説明します)

  • クエリプロシージャ上では、.queryを呼び出せること

  • ミューテーションプロシージャ上では、.mutateを呼び出せること

  • それ以外のものにアクセスしようとした場合、バックエンドに存在しないプロシージャであることを示す型エラーが発生すること

これらを実現する型を作成しましょう:

ts
type DecorateProcedure<TProcedure> = TProcedure extends AnyTRPCQueryProcedure
? {
query: Resolver<TProcedure>;
}
: TProcedure extends AnyTRPCMutationProcedure
? {
mutate: Resolver<TProcedure>;
}
: never;
ts
type DecorateProcedure<TProcedure> = TProcedure extends AnyTRPCQueryProcedure
? {
query: Resolver<TProcedure>;
}
: TProcedure extends AnyTRPCMutationProcedure
? {
mutate: Resolver<TProcedure>;
}
: never;

tRPC組み込みの推論ヘルパーの一部を使用して、プロシージャの入力/出力型を推論し、Resolver型を定義します。

ts
import type {
AnyTRPCProcedure,
inferProcedureInput,
inferProcedureOutput,
AnyTRPCQueryProcedure,
AnyTRPCMutationProcedure
} from '@trpc/server';
 
 
 
type Resolver<TProcedure extends AnyTRPCProcedure> = (
input: inferProcedureInput<TProcedure>,
) => Promise<inferProcedureOutput<TProcedure>>;
 
ts
import type {
AnyTRPCProcedure,
inferProcedureInput,
inferProcedureOutput,
AnyTRPCQueryProcedure,
AnyTRPCMutationProcedure
} from '@trpc/server';
 
 
 
type Resolver<TProcedure extends AnyTRPCProcedure> = (
input: inferProcedureInput<TProcedure>,
) => Promise<inferProcedureOutput<TProcedure>>;
 

post.byIdプロシージャで試してみましょう:

ts
type PostById = Resolver<AppRouter['post']['byId']>;
type PostById = (input: { id: string; }) => Promise<Post>
ts
type PostById = Resolver<AppRouter['post']['byId']>;
type PostById = (input: { id: string; }) => Promise<Post>

素晴らしい、期待通りです!プロシージャで.queryを呼び出し、正しい入力/出力型が推論されるようになりました。

最後に、ルーターを再帰的に走査し、通過する全てのプロシージャを装飾する型を作成します:

ts
import type { TRPCRouterRecord } from "@trpc/server";
import type { AnyTRPCRouter } from "@trpc/server";
 
type DecorateRouterRecord<TRecord extends TRPCRouterRecord> = {
[TKey in keyof TRecord]: TRecord[TKey] extends infer $Value
? $Value extends TRPCRouterRecord
? DecorateRouterRecord<$Value>
: $Value extends AnyTRPCProcedure
? DecorateProcedure<$Value>
: never
: never;
};
ts
import type { TRPCRouterRecord } from "@trpc/server";
import type { AnyTRPCRouter } from "@trpc/server";
 
type DecorateRouterRecord<TRecord extends TRPCRouterRecord> = {
[TKey in keyof TRecord]: TRecord[TKey] extends infer $Value
? $Value extends TRPCRouterRecord
? DecorateRouterRecord<$Value>
: $Value extends AnyTRPCProcedure
? DecorateProcedure<$Value>
: never
: never;
};

この型を少し解釈してみましょう:

  1. ジェネリックとしてTRPCRouterRecordを型に渡します。これはtRPCルーター上に存在する全てのプロシージャとサブルーターを含む型です。

  2. レコードのキー(手続きまたはルーター名)を反復処理し、以下を実行します:

    • キーがルーターを指す場合、そのルーターの手続きレコードに対して再帰的にこの型を適用し、そのルーター内のすべての手続きを装飾します。これによりパスをたどる際のオートコンプリートが提供されます。
    • キーが手続きを指す場合、先ほど作成した DecorateProcedure 型を使用して手続きを装飾します。
    • キーが手続きもルーターも指さない場合、never 型を割り当てます。これは「このキーは存在しない」ことを意味し、アクセスしようとすると型エラーが発生します。

🤯 Proxyを使ったリマッピング

型定義が整ったので、クライアント上でサーバーのルーター定義を拡張し、手続きを通常の関数のように呼び出せる機能を実際に実装します。

まず再帰的プロキシを作成するヘルパー関数 createRecursiveProxy を作成します:

情報

これはエッジケースの処理を除けば本番環境で使われている実装とほぼ同じです。実際のコードを参照してください

ts
interface ProxyCallbackOptions {
path: readonly string[];
args: readonly unknown[];
}
 
type ProxyCallback = (opts: ProxyCallbackOptions) => unknown;
 
function createRecursiveProxy(callback: ProxyCallback, path: readonly string[]) {
const proxy: unknown = new Proxy(
() => {
// dummy no-op function since we don't have any
// client-side target we want to remap to
},
{
get(_obj, key) {
if (typeof key !== 'string') return undefined;
 
// Recursively compose the full path until a function is invoked
return createRecursiveProxy(callback, [...path, key]);
},
apply(_1, _2, args) {
// Call the callback function with the entire path we
// recursively created and forward the arguments
return callback({
path,
args,
});
},
},
);
 
return proxy;
}
ts
interface ProxyCallbackOptions {
path: readonly string[];
args: readonly unknown[];
}
 
type ProxyCallback = (opts: ProxyCallbackOptions) => unknown;
 
function createRecursiveProxy(callback: ProxyCallback, path: readonly string[]) {
const proxy: unknown = new Proxy(
() => {
// dummy no-op function since we don't have any
// client-side target we want to remap to
},
{
get(_obj, key) {
if (typeof key !== 'string') return undefined;
 
// Recursively compose the full path until a function is invoked
return createRecursiveProxy(callback, [...path, key]);
},
apply(_1, _2, args) {
// Call the callback function with the entire path we
// recursively created and forward the arguments
return callback({
path,
args,
});
},
},
);
 
return proxy;
}

少し魔法のように見えますが、これは何をするのでしょうか?

  • get メソッドは post.byId のようなプロパティアクセスを処理します。キーはアクセスするプロパティ名で、例えば post と入力すると keypost に、post.byId と入力すると keybyId になります。再帰的プロキシはこれらすべてのキーを最終的なパス(例:["post", "byId", "query"])に結合し、リクエストを送信するURLを決定するために使用します。

  • apply メソッドは .query(args) のようにプロキシ上で関数を呼び出す際に実行されます。args は関数に渡す引数で、例えば post.byId.query(args) を呼び出すと args は入力値となり、手続きのタイプに応じてクエリパラメータまたはリクエストボディとして提供されます。createRecursiveProxy はコールバック関数を受け取り、apply をパスと引数でマッピングします。

以下は trpc.post.byId.query({ id: 1 }) の呼び出しにおけるプロキシの動作を視覚化したものです:

proxy

🧩 すべてを組み合わせる

このヘルパーとその動作が理解できたので、クライアント作成に使用しましょう。createRecursiveProxy にパスと引数を受け取り fetch を使ってサーバーにリクエストするコールバックを提供します。関数には任意のtRPCルーター型(AnyTRPCRouter)を受け入れるジェネリックを追加し、戻り型を先ほど作成した DecorateRouterRecord 型にキャストします:

ts
import { TRPCResponse } from '@trpc/server/rpc';
 
export const createTinyRPCClient = <TRouter extends AnyTRPCRouter>(
baseUrl: string,
) =>
createRecursiveProxy(async (opts) => {
const path = [...opts.path]; // e.g. ["post", "byId", "query"]
const method = path.pop()! as 'query' | 'mutate';
const dotPath = path.join('.'); // "post.byId" - this is the path procedures have on the backend
let uri = `${baseUrl}/${dotPath}`;
 
const [input] = opts.args;
const stringifiedInput = input !== undefined && JSON.stringify(input);
let body: undefined | string = undefined;
if (stringifiedInput !== false) {
if (method === 'query') {
uri += `?input=${encodeURIComponent(stringifiedInput)}`;
} else {
body = stringifiedInput;
}
}
 
const json: TRPCResponse = await fetch(uri, {
method: method === 'query' ? 'GET' : 'POST',
headers: {
'Content-Type': 'application/json',
},
body,
}).then((res) => res.json());
 
if ('error' in json) {
throw new Error(`Error: ${json.error.message}`);
}
// No error - all good. Return the data.
return json.result.data;
}, []) as DecorateRouterRecord<TRouter['_def']['record']>;
// ^? provide empty array as path to begin with
ts
import { TRPCResponse } from '@trpc/server/rpc';
 
export const createTinyRPCClient = <TRouter extends AnyTRPCRouter>(
baseUrl: string,
) =>
createRecursiveProxy(async (opts) => {
const path = [...opts.path]; // e.g. ["post", "byId", "query"]
const method = path.pop()! as 'query' | 'mutate';
const dotPath = path.join('.'); // "post.byId" - this is the path procedures have on the backend
let uri = `${baseUrl}/${dotPath}`;
 
const [input] = opts.args;
const stringifiedInput = input !== undefined && JSON.stringify(input);
let body: undefined | string = undefined;
if (stringifiedInput !== false) {
if (method === 'query') {
uri += `?input=${encodeURIComponent(stringifiedInput)}`;
} else {
body = stringifiedInput;
}
}
 
const json: TRPCResponse = await fetch(uri, {
method: method === 'query' ? 'GET' : 'POST',
headers: {
'Content-Type': 'application/json',
},
body,
}).then((res) => res.json());
 
if ('error' in json) {
throw new Error(`Error: ${json.error.message}`);
}
// No error - all good. Return the data.
return json.result.data;
}, []) as DecorateRouterRecord<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を指定すれば、手続きを呼び出す際に完全なオートコンプリートと型安全性が得られます!

ts
const url = 'http://localhost:3000/api/trpc';
const client = createTinyRPCClient<AppRouter>(url);
 
// 🧙‍♀️ magic autocompletion
client.post.b;
             
//
 
// 👀 fully typesafe
const post = await client.post.byId.query({ id: '123' });
const post: { id: string; title: string; }
ts
const url = 'http://localhost:3000/api/trpc';
const client = createTinyRPCClient<AppRouter>(url);
 
// 🧙‍♀️ magic autocompletion
client.post.b;
             
//
 
// 👀 fully typesafe
const post = await client.post.byId.query({ id: '123' });
const post: { id: string; title: string; }

クライアントの完全なコードはこちらで、使用方法を示すテストはこちらで確認できます。

まとめ

この記事が楽しめ、tRPCの仕組みについて学びが得られたなら幸いです。ただし今回作成したクライアントではなく、ほんの数KB大きいだけの@trpc/clientを使用することをお勧めします。公式クライアントにはここで紹介した機能以上の柔軟性が備わっています:

  • 中止シグナルやSSR対応などのクエリオプション

  • リンク機能

  • 手続きのバッチ処理

  • WebSocket/サブスクリプション対応

  • 洗練されたエラーハンドリング

  • データ変換機能

  • tRPC準拠でないレスポンスへのエッジケース対応

サーバー側の詳細については今回は深く触れませんでしたが、今後の記事で取り上げるかもしれません。質問があれば、Twitterでお気軽に連絡ください。