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

·12分で読めます
Julius Marminge

tRPC がどのように動作するのか疑問に思ったことはありませんか?もしかしたら、プロジェクトへの貢献を始めたいけれど、内部構造に恐れを抱いているかもしれませんか?この投稿の目的は、tRPC の動作の大きな部分をカバーする最小限のクライアントを作成することで、tRPC の内部構造に慣れてもらうことです。

情報

ジェネリクス、条件型、extends キーワード、再帰など、TypeScript のコアコンセプトを理解していることをお勧めします。これらに馴染みがない場合は、Matt Pocock 氏の Beginner 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. ジェネリックとして TRPCRouterRecord を型に渡します。これは、tRPC ルーターに存在するすべてのプロシージャとサブルーターを含む型です。
  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

🧩 すべてをまとめる

このヘルパーがあり、その動作がわかったので、それを使用してクライアントを作成しましょう。パスと引数を受け取り、fetch を使用してサーバーにリクエストするコールバックを createRecursiveProxy に提供します。任意の 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 準拠の応答形式を決定します。詳細については、こちらを参照してください。簡単に言うと、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 などのクエリオプション...
  • リンク
  • プロシージャバッチ処理
  • WebSockets / サブスクリプション
  • 優れたエラー処理
  • データトランスフォーマー
  • tRPC 準拠の応答が得られない場合などのエッジケースの処理

また、今日はサーバーサイドのことについてはあまり触れませんでした。おそらく今後の記事でそれについて説明します。ご質問があれば、Twitter で遠慮なく私にご連絡ください。

·9分で読めます
Sachin Raja

ライブラリの作成者としての私たちの目標は、同僚である開発者に対して可能な限り最高の開発者体験 (DX) を提供することです。エラー発生までの時間を短縮し、直感的な API を提供することで、開発者の頭の中から精神的な負担を取り除き、最も重要なこと、つまり優れたエンドユーザー体験に集中できるようにします。


TypeScript が tRPC の素晴らしい DX を実現する原動力であることは周知の事実です。TypeScript の採用は、今日、優れた JavaScript ベースのエクスペリエンスを提供するための現代的な標準となっています。しかし、型に関するこの確実性の向上には、いくつかのトレードオフがあります。

現在、TypeScript の型チェッカーは処理が遅くなりがちです(ただし、TS 4.9 のようなリリースは有望です!)。ライブラリには、ほぼ常にコードベースで最も高度な TypeScript の呪文が含まれており、TS コンパイラを限界まで酷使しています。そのため、私たちのようなライブラリの作成者は、その負担への貢献を意識し、IDE が可能な限り高速に動作するように最善を尽くす必要があります。

ライブラリのパフォーマンスの自動化

tRPC が v9 の段階にあったとき、大規模な tRPC ルーターが型チェッカーに悪影響を及ぼし始めているという開発者からの報告を受け始めました。これは、tRPC の開発の v9 フェーズで大きな採用が見られたため、tRPC にとっては新しい経験でした。より多くの開発者が tRPC を使用してますます大規模な製品を作成するにつれて、いくつかのひび割れが見え始めました。

お使いのライブラリは現在は遅くないかもしれませんが、ライブラリが成長し、変化するにつれてパフォーマンスを監視することが重要です。自動テストは、コミットごとにライブラリコードをプログラムでテストすることにより、ライブラリ作成(およびアプリケーション構築!)から大きな負担を取り除くことができます。

tRPC では、生成およびテストすることで、3,500 のプロシージャと 1,000 のルーターを持つルーターをテストして、これが保証されるように最善を尽くしています。ただし、これは、TS コンパイラが壊れる前にどこまでプッシュできるかをテストするだけであり、型チェックにかかる時間をテストするものではありません。ライブラリの 3 つの部分(サーバー、バニラクライアント、React クライアント)はすべて異なるコードパスを持っているため、それらをすべてテストします。過去には、ライブラリの 1 つのセクションに隔離された回帰が見られ、これらの予期しない動作が発生したときにテストに頼っていました。(コンパイル時間を測定するために、さらにやりたいことがあります)

tRPC は実行時負荷の高いライブラリではないため、パフォーマンス指標は型チェックを中心にしています。したがって、私たちは以下の点に留意しています。

  • tsc を使用した型チェックが遅い
  • 初期ロード時間が長い
  • TypeScript 言語サーバーが変更に応答するのに時間がかかる

最後の点は、tRPC が最も注意を払う必要がある点です。開発者が変更後に言語サーバーが更新されるのを待たなければならないような状態には、決してしたくありません。これは、優れた DX を享受できるように、tRPC がパフォーマンスを維持する必要がある場所です。

tRPC でパフォーマンスの機会を見つけた方法

TypeScript の正確さとコンパイラのパフォーマンスの間には常にトレードオフがあります。どちらも他の開発者にとって重要な懸念事項であるため、型を記述する方法を非常に意識する必要があります。特定の型が「緩すぎる」ために、アプリケーションが深刻なエラーに陥る可能性はありますか?パフォーマンスの向上に見合う価値はありますか?

そもそも意味のあるパフォーマンスの向上が見られるのでしょうか?良い質問です。

TypeScript コードでパフォーマンスを向上させる機会を見つける方法を見てみましょう。PR #2716 を作成するまでに行ったプロセスを見てみましょう。これにより、TS コンパイル時間が 59% 削減されました。


TypeScript には、型内のボトルネックを見つけるのに役立つ組み込みのトレースツールがあります。完璧ではありませんが、利用可能な最良のツールです。

実際の開発者がライブラリをどのように使用するかをシミュレートするために、実際のアプリでライブラリをテストするのが理想的です。tRPC の場合、多くのユーザーが使用しているものに似た基本的なT3 アプリを作成しました。

tRPC をトレースするために従った手順は次のとおりです。

  1. ライブラリをローカルでサンプルアプリにリンクします。これにより、ライブラリコードを変更して、変更をすぐにローカルでテストできます。

  2. サンプルアプリでこのコマンドを実行します

    sh
    tsc --generateTrace ./trace --incremental false
    sh
    tsc --generateTrace ./trace --incremental false
  3. マシンに trace/trace.json ファイルが生成されます。そのファイルをトレース分析アプリ(私はPerfettoを使用しています)または chrome://tracing で開くことができます。

ここからが面白いところです。アプリケーション内の型のパフォーマンスプロファイルについて学び始めることができます。最初のトレースは次のようになりました。 src/pages/index.ts の型チェックに 332ms かかったことを示すトレースバー

バーが長いほど、そのプロセスの実行に時間がかかっていることを意味します。このスクリーンショットでは、一番上の緑色のバーを選択しました。これは、src/pages/index.ts がボトルネックであることを示しています。Duration フィールドには、型チェックに 332 ミリ秒かかったと表示されています。これは、型チェックに費やすには膨大な時間です!青色の checkVariableDeclaration バーは、コンパイラが 1 つの変数にほとんどの時間を費やしたことを示しています。そのバーをクリックすると、どの変数かがわかります:変数の位置が 275 であることを示すトレース情報 pos フィールドは、ファイルテキスト内の変数の位置を示しています。src/pages/index.ts のその位置に移動すると、犯人が utils = trpc.useContext() であることがわかります!

しかし、どうしてこうなるのでしょうか?私たちは単純なフックを使用しているだけです!コードを見てみましょう

tsx
import type { AppRouter } from '~/server/trpc';
const trpc = createTRPCReact<AppRouter>();
const Home: NextPage = () => {
const { data } = trpc.r0.greeting.useQuery({ who: 'from tRPC' });
const utils = trpc.useContext();
utils.r49.greeting.invalidate();
};
export default Home;
tsx
import type { AppRouter } from '~/server/trpc';
const trpc = createTRPCReact<AppRouter>();
const Home: NextPage = () => {
const { data } = trpc.r0.greeting.useQuery({ who: 'from tRPC' });
const utils = trpc.useContext();
utils.r49.greeting.invalidate();
};
export default Home;

はい、ここにはあまり見るべきものはありません。単一の useContext とクエリの無効化が表示されるだけです。表面的には TypeScript に負荷がかかるようなものはないため、問題はスタックのより深いところにあるはずです。この変数の背後にある型を見てみましょう

ts
type DecorateProcedure<
TRouter extends AnyRouter,
TProcedure extends Procedure<any>,
TProcedure extends AnyQueryProcedure,
> = {
/**
* @link https://tanstack.com/query/v4/docs/framework/react/guides/query-invalidation
*/
invalidate(
input?: inferProcedureInput<TProcedure>,
filters?: InvalidateQueryFilters,
options?: InvalidateOptions,
): Promise<void>;
// ... and so on for all the other React Query utilities
};
export type DecoratedProcedureUtilsRecord<TRouter extends AnyRouter> =
OmitNeverKeys<{
[TKey in keyof TRouter['_def']['record']]: TRouter['_def']['record'][TKey] extends LegacyV9ProcedureTag
? never
: TRouter['_def']['record'][TKey] extends AnyRouter
? DecoratedProcedureUtilsRecord<TRouter['_def']['record'][TKey]>
: TRouter['_def']['record'][TKey] extends AnyQueryProcedure
? DecorateProcedure<TRouter, TRouter['_def']['record'][TKey]>
: never;
}>;
ts
type DecorateProcedure<
TRouter extends AnyRouter,
TProcedure extends Procedure<any>,
TProcedure extends AnyQueryProcedure,
> = {
/**
* @link https://tanstack.com/query/v4/docs/framework/react/guides/query-invalidation
*/
invalidate(
input?: inferProcedureInput<TProcedure>,
filters?: InvalidateQueryFilters,
options?: InvalidateOptions,
): Promise<void>;
// ... and so on for all the other React Query utilities
};
export type DecoratedProcedureUtilsRecord<TRouter extends AnyRouter> =
OmitNeverKeys<{
[TKey in keyof TRouter['_def']['record']]: TRouter['_def']['record'][TKey] extends LegacyV9ProcedureTag
? never
: TRouter['_def']['record'][TKey] extends AnyRouter
? DecoratedProcedureUtilsRecord<TRouter['_def']['record'][TKey]>
: TRouter['_def']['record'][TKey] extends AnyQueryProcedure
? DecorateProcedure<TRouter, TRouter['_def']['record'][TKey]>
: never;
}>;

はい、これでいくつかのことを解明して学ぶべきことがあります。まず、このコードが何をしているのかを理解しましょう。

再帰型 DecoratedProcedureUtilsRecord は、ルーター内のすべてのプロシージャを調べて、invalidateQueries のような React Query ユーティリティでそれらを「装飾」(メソッドを追加)します。

tRPC v10 では、古い v9 ルーターも引き続きサポートしていますが、v10 クライアントは v9 ルーターのプロシージャを呼び出すことはできません。したがって、各プロシージャについて、それが v9 プロシージャ(extends LegacyV9ProcedureTag)であるかどうかを確認し、そうであれば削除します。これはすべて、TypeScript にとって大変な作業です...遅延評価されなければ

遅延評価

ここでの問題は、TypeScript が、すぐに使用されていないにもかかわらず、タイプシステム内のこのすべてのコードを評価していることです。私たちのコードは utils.r49.greeting.invalidate のみを使用しているため、TypeScript は r49 プロパティ(ルーター)、次に greeting プロパティ(プロシージャ)、最後にそのプロシージャの invalidate 関数のみをアンラップする必要があります。そのコードでは他の型は必要なく、すべての tRPC プロシージャのすべての React Query ユーティリティメソッドの型をすぐに検索すると、TypeScript が不必要に遅くなります。TypeScript は、直接使用されるまで、オブジェクトのプロパティの型評価を延期するため、理論的には上記の型は遅延評価されるはずです...そうですよね?

まあ、それは正確にオブジェクトではありません。実際には、全体をラップする型があります:OmitNeverKeys。この型は、オブジェクトから値が never のキーを削除するユーティリティです。これは、v9 プロシージャを削除して、それらのプロパティが Intellisense に表示されないようにする部分です。

しかし、これにより、大きなパフォーマンスの問題が発生します。TypeScript に、それらが never であるかどうかを確認するために、すべての型の値を評価することを強制しました。

これをどうすれば修正できますか?型をより少ないことを行うように変更しましょう。

遅延評価を利用する

v10 API がレガシーな v9 ルーターに、より適切に適応する方法を見つける必要があります。新しい tRPC プロジェクトは、インターオペモードの TypeScript のパフォーマンス低下の影響を受けるべきではありません。

そのアイデアは、コア型自体を再配置することです。v9 プロシージャは v10 プロシージャとは異なるエンティティであるため、ライブラリコードで同じスペースを共有すべきではありません。tRPC サーバー側では、これは、単一の record フィールドではなく、ルーターの異なるフィールドに型を格納するために行うべき作業がいくつかあることを意味します(上記の DecoratedProcedureUtilsRecord を参照)。

v9 ルーターが v10 ルーターに変換されるときに、それらのプロシージャを legacy フィールドに挿入するように変更しました。

古い型

ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
};
// convert a v9 interop router to a v10 router
export type MigrateV9Router<TV9Router extends V9Router> = V10Router<{
[TKey in keyof TV9Router['procedures']]: MigrateProcedure<
TV9Router['procedures'][TKey]
> &
LegacyV9ProcedureTag;
}>;
ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
};
// convert a v9 interop router to a v10 router
export type MigrateV9Router<TV9Router extends V9Router> = V10Router<{
[TKey in keyof TV9Router['procedures']]: MigrateProcedure<
TV9Router['procedures'][TKey]
> &
LegacyV9ProcedureTag;
}>;

上記の DecoratedProcedureUtilsRecord 型を思い出してください。ここで LegacyV9ProcedureTag をアタッチして、型レベルで v9 プロシージャと v10 プロシージャを区別し、v9 プロシージャが v10 クライアントから呼び出されないように強制したことがわかります。

新しい型

ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
// by default, no legacy procedures
legacy: {};
};
export type MigrateV9Router<TV9Router extends V9Router> = {
// v9 routers inject their procedures into a `legacy` field
legacy: {
// v9 clients require that we filter queries, mutations, subscriptions at the top-level
queries: MigrateProcedureRecord<TV9Router['queries']>;
mutations: MigrateProcedureRecord<TV9Router['mutations']>;
subscriptions: MigrateProcedureRecord<TV9Router['subscriptions']>;
};
} & V10Router</* empty object, v9 routers have no v10 procedures to pass */ {}>;
ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
// by default, no legacy procedures
legacy: {};
};
export type MigrateV9Router<TV9Router extends V9Router> = {
// v9 routers inject their procedures into a `legacy` field
legacy: {
// v9 clients require that we filter queries, mutations, subscriptions at the top-level
queries: MigrateProcedureRecord<TV9Router['queries']>;
mutations: MigrateProcedureRecord<TV9Router['mutations']>;
subscriptions: MigrateProcedureRecord<TV9Router['subscriptions']>;
};
} & V10Router</* empty object, v9 routers have no v10 procedures to pass */ {}>;

これで、プロシージャが事前ソートされているため、OmitNeverKeys を削除できます。したがって、ルーターの record プロパティ型にはすべての v10 プロシージャが含まれ、legacy プロパティ型にはすべての v9 プロシージャが含まれます。TypeScript に巨大な DecoratedProcedureUtilsRecord 型を完全に評価することを強制しなくなりました。LegacyV9ProcedureTag を使用した v9 プロシージャのフィルタリングも削除できます。

うまくいきましたか?

新しいトレースは、ボトルネックが解消されたことを示しています: src/pages/index.ts の型チェックに 136ms かかったことを示すトレースバー

大幅な改善です!型チェック時間が 332 ミリ秒から 136 ミリ秒に短縮されました 🤯!これは全体で見るとそれほど大きなことではないように思えるかもしれませんが、非常に大きな勝利です。200 ミリ秒は一度ではわずかな量ですが、次のことを考えてみてください。

  • プロジェクトに他の TS ライブラリがどれだけあるか
  • 今日、tRPC を使用している開発者の数
  • 作業セッションで型が再評価される回数

それは、非常に大きな数になる 200 ミリ秒の合計です。

tRPC であろうと、別のプロジェクトで解決する TS ベースの問題であろうと、私たちは常に TypeScript 開発者のエクスペリエンスを向上させる機会を探しています。TypeScript について話したい場合は、Twitter で私にメンションしてください。

この投稿の執筆を手伝ってくれた Anthony Shew と、レビューしてくれた Alex に感謝します!

·4分で読めます
Alex / KATT 🐱

tRPC は、TypeScript の力を通じて、厳密でフルスタックの型バインディングを強制することで、優れた開発者エクスペリエンスを提供します。API コントラクトのずれやコード生成はありません。

2021年8月の前回のメジャーバージョンリリース以来、tRPCコミュニティは著しい成長を遂げてきました。

本日、tRPC v10をリリースします。v10はすでに多くの大規模なTypeScriptプロジェクトで本番環境で使用されており、今回の正式リリースで、より広いコミュニティで一般利用が可能になることを発表します。

新規プロジェクトでは、サンプルアプリケーションを使用してtRPC v10について学ぶことができます。すでにtRPC v9をご利用のプロジェクトについては、v10移行ガイドをご覧ください。

変更点の概要

v10はtRPC史上最大のリリースです。tRPCの構造に根本的な変更を加えたのは今回が初めてであり、この変更により、最先端のアプリケーションに取り組む機動的なチームにとって、新たな可能性が開かれると信じています。

開発者エクスペリエンスの向上

tRPC v10はIDEを最大限に活用します。型を統合したいだけでなく、このバージョンではフロントエンド、バックエンド、編集エクスペリエンスも統合しました。

v10では、次のことが可能になります。

  • 「定義へ移動」を使用して、フロントエンドのコンシューマーからバックエンドのプロシージャに直接ジャンプ
  • 「シンボルの名前変更」を使用して、アプリケーション全体で入力引数またはプロシージャに新しい名前を付ける
  • アプリケーションでtRPCの型を手動で使用したい場合に、型の推論をより簡単にする

強力なバックエンドフレームワーク

v10では、バックエンドプロシージャを定義する方法の構文を見直し、望ましいロジックを健全な方法で取り込む機会を増やしました。このバージョンのtRPCには、次の機能があります。

大幅に改善されたTypeScriptのパフォーマンス

TypeScriptは開発者が驚くべきことを可能にしますが、それにはコストがかかる場合があります。型を厳密に保つために使用する多くの手法は、TypeScriptコンパイラに大きな負荷をかけます。tRPC v9を使用している最大規模のアプリケーションでは、このコンパイラの負荷の結果として、開発者のIDEでのパフォーマンスが低下し始めているというコミュニティからのフィードバックがありました。

私たちの目標は、あらゆる規模のアプリケーションで開発者エクスペリエンスを向上させることです。v10では、TypeScriptのパフォーマンス(特にTSのインクリメンタルコンパイル)を大幅に改善し、エディターの動作を軽快に保てるようにしました。

インクリメンタルな移行

また、v9ルーターとの(ほぼ)完全な後方互換性を実現するinterop()メソッドを含め、移行エクスペリエンスを可能な限り簡潔にするために多大な労力を費やしました。詳細については、移行ガイドをご覧ください。

コアチームのSachinは、移行の大部分を自動化できるcodemodも作成しました。

成長を続けるエコシステム

tRPCを中心に、豊富なサブライブラリが形成され続けています。以下にいくつかの例を示します。

  • REST互換のエンドポイントを簡単に作成するためのtrpc-openapi
  • tRPCを使用したフルスタックのNext.jsアプリケーションをブートストラップするためのcreate-t3-app
  • tRPCで次のReact Nativeアプリを始めるためのcreate-t3-turbo
  • tRPCを使用してChrome拡張機能を構築するためのtrpc-chrome
  • SolidSvelteVueなどのフレームワーク用のアダプター

その他のプラグイン、サンプル、アダプターについては、Awesome tRPCコレクションをご覧ください。

ありがとうございます!

コアチームと私から皆様へお伝えしたいことがあります。私たちはまだ始まったばかりです。React Server ComponentsとNext.js 13の実験にすでに取り組んでいます。

また、このリリースを可能にしたSachinJuliusJamesAhmedChrisTheoAnthony、そしてすべての貢献者に心から感謝します。

tRPCをご利用いただき、ご支援ありがとうございます。


·5分間の読み物
Alex / KATT 🐱

私はAlex、GitHubでは「KATT」です。今日はtRPCというライブラリについてお話ししたいと思います。このライブラリに関する記事はまだ公開していませんが、この紹介記事をきっかけに勢いをつけたいと思います(しかし、すでにGitHubでは530以上の🌟を獲得しています)。今後の記事やビデオでの紹介にご期待ください!最新情報を入手したい場合や質問がある場合は、Twitterで私をフォローしてください:@alexdotjs

要するに、tRPCは(node)サーバーからクライアントまで、型を宣言することなくエンドツーエンドの型安全性を提供します。バックエンドで行うことは、関数でデータを返すことだけであり、フロントエンドではエンドポイント名に基づいてそのデータを使用します。

tRPCエンドポイントとクライアントの呼び出しは、次のようになります。 代替テキスト

React用のライブラリ(@trpc/react)は、素晴らしいreact-queryの上に構築されていますが、クライアントライブラリ(@trpc/client)はReactなしでも動作します(特定のSvelte/Vue/Angular/ライブラリを構築したい場合はご連絡ください!)[..]ライブラリを構築したい場合はご連絡ください!

コード生成は不要で、既存のNext.js/CRA/Expressプロジェクトに簡単に追加できます。

これは、string引数を受け取るhelloというtRPCプロシージャ(別名エンドポイント)の例です。

tsx
const appRouter = trpc.router().query('hello', {
input: z.string().optional(),
resolve: ({ input }) => {
return {
text: `hello ${input ?? 'world'}`,
};
},
});
export type AppRouter = typeof appRouter;
tsx
const appRouter = trpc.router().query('hello', {
input: z.string().optional(),
resolve: ({ input }) => {
return {
text: `hello ${input ?? 'world'}`,
};
},
});
export type AppRouter = typeof appRouter;

そして、これがそのデータを使用する型安全なクライアントです。

tsx
import type { AppRouter } from './server';
async function main() {
const client = createTRPCClient<AppRouter>({
url: `http://localhost:2022`,
});
const result = await client.query('hello', '@alexdotjs');
console.log(result); // --> { text: "hello @alexdotjs" }
}
main();
tsx
import type { AppRouter } from './server';
async function main() {
const client = createTRPCClient<AppRouter>({
url: `http://localhost:2022`,
});
const result = await client.query('hello', '@alexdotjs');
console.log(result); // --> { text: "hello @alexdotjs" }
}
main();

型安全性を実現するにはこれだけです! resultは、バックエンドが関数で返すものから型推論されます。入力からのデータもバリデーターの戻り値から推論されるため、そのデータをそのまま安全に使用できます。実際には、入力データをバリデーターに通す必要があります(また、tRPCはzod/yup/カスタムバリデーターをすぐに利用できます)。

上記の例を試せるCodeSandboxリンクはこちらです:https://githubbox.com/trpc/trpc/tree/next/examples/standalone-server(プレビューではなくターミナルの出力を見てください!)

え?バックエンドからクライアントにコードをインポートしている? - いいえ、実際にはそうではありません

そう見えるかもしれませんが、サーバーからクライアントにコードは共有されません。TypeScriptのimport type[..]型の注釈と宣言に使用される宣言のみをインポートします。常に完全に消去されるため、ランタイムには残骸は残りません。これはTypeScript 3.8で追加された機能です - TypeScriptのドキュメントを参照してください

コード生成は必要なく、サーバーからクライアントに型を共有する方法があれば、今日からアプリにこれを追加できます(できればすでにモノレポを使用しているでしょう)。

しかし、これはまだ始まりに過ぎません!

前述したように、Reactライブラリがあり、Reactで上記のデータを使用する方法は次のとおりです。

tsx
const { data } = trpc.useQuery(['hello', '@alexdotjs']);
tsx
const { data } = trpc.useQuery(['hello', '@alexdotjs']);

..そうすると、クライアントで型安全なデータが得られます。

tRPCは既存のブラウンフィールドプロジェクト(Express/Next.js用のアダプターあり)に追加できます。CRAでも正常に動作し、React Nativeでも動作するはずです。Reactに限定されているわけではないため、SvelteやVueのライブラリを作成したい場合は、私にご連絡ください。

データのミューテーションはどうすればいいですか?

ミューテーションはクエリと同じくらい簡単に行えます。実際には内部的には同じですが、構文糖衣として異なる方法で公開されており、GETリクエストではなくHTTP POSTを生成します。

データベースを使ったもう少し複雑な例を紹介します。これは、todomvc.trpc.ioにあるTodoMVCの例から取ってきたものです。/ https://github.com/trpc/trpc/tree/next/examples/next-prisma-todomvc

tsx
const todoRouter = createRouter().mutation('add', {
input: z.object({
id: z.string().uuid(),
data: z.object({
completed: z.boolean().optional(),
text: z.string().min(1).optional(),
}),
}),
async resolve({ ctx, input }) {
const { id, data } = input;
const todo = await ctx.task.update({
where: { id },
data,
});
return todo;
},
});
tsx
const todoRouter = createRouter().mutation('add', {
input: z.object({
id: z.string().uuid(),
data: z.object({
completed: z.boolean().optional(),
text: z.string().min(1).optional(),
}),
}),
async resolve({ ctx, input }) {
const { id, data } = input;
const todo = await ctx.task.update({
where: { id },
data,
});
return todo;
},
});

そして、Reactでの使い方はこんな感じです。

tsx
const addTask = trpc.useMutation('todos.add');
return (
<>
<input
placeholder="What needs to be done?"
onKeyDown={(e) => {
const text = e.currentTarget.value.trim();
if (e.key === 'Enter' && text) {
addTask.mutate({ text });
e.currentTarget.value = '';
}
}}
/>
</>
)
tsx
const addTask = trpc.useMutation('todos.add');
return (
<>
<input
placeholder="What needs to be done?"
onKeyDown={(e) => {
const text = e.currentTarget.value.trim();
if (e.key === 'Enter' && text) {
addTask.mutate({ text });
e.currentTarget.value = '';
}
}}
/>
</>
)

とりあえず、ここまで。

とにかく、言いたかったのは、まずは始めるということです。他にもたくさんあります。

  • リゾルバーに依存性注入されるユーザー固有のデータに対するリクエストのコンテキストの作成 - リンク
  • ルーターのミドルウェアサポート - リンク
  • ルーターのマージ(おそらく、すべてのバックエンドデータを1つのファイルにまとめたいとは思わないでしょう)- リンク
  • Reactの世界でこれまで見た中で最もシンプルなサーバーサイドレンダリングは、@trpc/nextアダプターを使います - リンク
  • タイプセーフなエラーフォーマット - リンク
  • データトランスフォーマー(ネットワークを介してDate/Map/Setオブジェクトを使用) - リンク
  • React Queryのヘルパー

もし始めたい場合は、Next.jsの始め方にいくつか例があります。

更新情報については、Twitterでフォローしてください!