本文へスキップ

小さなtRPCクライアントの作成

·12分読み
Julius Marminge

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では、これをプロシージャのデコレーションと呼びます。@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. ジェネリックとして、tRPCルーター上に存在するすべてのプロシージャとサブルーターを含む型であるTRPCRouterRecordを型に渡します。
  2. レコードのキー(プロシージャ名またはルーター名)を反復処理し、次の操作を行います。
    • キーがルーターにマップされている場合、そのルーターのプロシージャレコードに対して型を再帰的に呼び出します。これにより、パスをトラバースする際にオートコンプリートが提供されます。
    • キーがプロシージャにマップされている場合、前に作成したDecorateProcedure型を使用してプロシージャをデコレートします。
    • キーがプロシージャまたはルーターにマップされていない場合、never型を割り当てます。これは、「このキーは存在しません」という意味で、アクセスしようとすると型エラーが発生します。

🤯 Proxyのリマッピング

すべての型が設定されたので、サーバーのルーター定義をクライアント上で拡張して、プロシージャを通常の関数のように呼び出せるようにする機能を実装する必要があります。

まず、再帰的なプロキシを作成するためのヘルパー関数createRecursiveProxyを作成します。

情報

これは、いくつかのエッジケースを処理していない点を除いて、本番環境で使用されている実装とほぼ同じです。自分で確認してください

ts
interface ProxyCallbackOptions {
path: string[];
args: unknown[];
}
 
type ProxyCallback = (opts: ProxyCallbackOptions) => unknown;
 
function createRecursiveProxy(callback: ProxyCallback, path: 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: string[];
args: unknown[];
}
 
type ProxyCallback = (opts: ProxyCallbackOptions) => unknown;
 
function createRecursiveProxy(callback: ProxyCallback, path: 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ハンドラーを備え、すべてのリクエストを処理することができ、プロシージャごとに1つずつハンドラーを用意する必要がありません。Next.jsなどのファイルベースのルーティングを使用するフレームワークを使用している場合、すべてのプロシージャパスに一致するキャッチオール/api/trpc/[trpc].tsファイルをご存知かもしれません。

また、fetchリクエストにTRPCResponse型アノテーションを追加しました。これは、サーバーが応答するJSONRPC準拠のレスポンス形式を決定します。これについてはこちらで詳しく読むことができます。TL;DR: 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の仕組みについて何かを学んでいただければ幸いです。@trpc/clientの方が数KBしか大きくなく、こちらで紹介したものよりもはるかに柔軟性が高いので、これを使用することはお勧めしません。

  • 中断シグナル、SSRなどのクエリオプション…
  • リンク
  • プロシージャのバッチ処理
  • WebSockets / サブスクリプション
  • 優れたエラー処理
  • データトランスフォーマー
  • tRPC準拠のレスポンスが得られない場合などのエッジケース処理

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