Aller au contenu principal

Écrire un petit client tRPC

· 11 min de lecture
Julius Marminge
tRPC Core Team Member
Traduction Bêta Non Officielle

Cette page a été traduite par PageTurner AI (bêta). Non approuvée officiellement par le projet. Vous avez trouvé une erreur ? Signaler un problème →

Vous êtes-vous déjà demandé comment fonctionne tRPC ? Peut-être souhaitez-vous contribuer au projet mais les rouages internes vous effraient ? L'objectif de cet article est de vous familiariser avec le fonctionnement interne de tRPC en écrivant un client minimal qui couvre les grandes parties de son fonctionnement.

info

Il est recommandé de comprendre certains concepts fondamentaux de TypeScript comme les génériques, les types conditionnels, le mot-clé extends et la récursivité. Si vous ne maîtrisez pas ces notions, je vous conseille de suivre le tutoriel Beginner TypeScript de Matt Pocock avant de poursuivre.

Aperçu

Supposons que nous ayons un routeur tRPC simple avec trois procédures comme ceci :

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;
}),
}),
});

L'objectif de notre client est de reproduire cette structure d'objet côté client pour pouvoir appeler des procédures ainsi :

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' });

Pour y parvenir, tRPC utilise une combinaison d'objets Proxy et d'un peu de magie TypeScript pour enrichir la structure avec les méthodes .query et .mutate - ce qui signifie que nous vous MENTONS en réalité sur ce que vous faites (nous y reviendrons) pour offrir une excellente expérience développeur !

Globalement, nous voulons mapper post.byId.query() vers une requête GET sur notre serveur, et post.create.mutate() vers une requête POST, avec une propagation complète des types du back vers le front. Alors, comment procéder ?

Implémentation d'un petit client tRPC

🧙‍♀️ La magie TypeScript

Commençons par la magie amusante de TypeScript pour débloquer l'autocomplétion géniale et la sécurité de type qu'on adore dans tRPC.

Nous utiliserons des types récursifs pour inférer des structures de routeur arbitrairement profondes. De plus, nous voulons que nos procédures post.byId et post.create aient respectivement les méthodes .query et .mutate - dans tRPC, on appelle cela "décorer" les procédures. Dans @trpc/server, des helpers d'inférence déduisent les types d'entrée/sortie des procédures avec ces méthodes résolues, que nous exploiterons pour typer ces fonctions. Alors, codons !

Voyons ce que nous voulons accomplir pour fournir l'autocomplétion des chemins et l'inférence des types d'entrée/sortie des procédures :

  • Sur un routeur, nous devons accéder à ses sous-routeurs et procédures. (nous y reviendrons)

  • Sur une procédure query, nous devons pouvoir appeler .query

  • Sur une procédure mutation, nous devons pouvoir appeler .mutate

  • Pour tout autre accès, nous voulons une erreur de type indiquant l'absence de la procédure côté serveur

Créons donc un type qui implémente ce comportement :

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;

Nous utiliserons des helpers d'inférence internes de tRPC pour définir le type Resolver en déduisant les types d'entrée/sortie des procédures.

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>>;
 

Testons cela sur notre procédure 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>

Parfait ! Nous pouvons maintenant appeler .query sur notre procédure et obtenir les types d'entrée/sortie correctement déduits.

Enfin, créons un type qui parcourt récursivement le routeur et décore toutes les procédures rencontrées :

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;
};

Décortiquons un peu ce type :

  1. Nous passons un TRPCRouterRecord comme générique, qui représente l'ensemble des procédures et sous-routeurs d'un routeur tRPC.

  2. Nous itérons sur les clés de l'enregistrement, qui sont les noms des procédures ou routeurs, et effectuons les actions suivantes :

    • Si la clé correspond à un routeur, nous appelons récursivement le type sur l'enregistrement des procédures de ce routeur, ce qui décorera toutes les procédures de ce routeur. Cela fournira l'autocomplétion lors de la navigation dans le chemin.
    • Si la clé correspond à une procédure, nous la décorons en utilisant le type DecorateProcedure créé précédemment.
    • Si la clé ne correspond ni à une procédure ni à un routeur, nous assignons le type never, ce qui équivaut à dire "cette clé n'existe pas" et générera une erreur de type si on tente d'y accéder.

🤯 Le remaniement par Proxy

Maintenant que nos types sont configurés, nous devons implémenter la fonctionnalité qui enrichira la définition du routeur côté client pour pouvoir invoquer les procédures comme des fonctions normales.

Nous allons d'abord créer une fonction utilitaire pour générer des proxies récursifs - createRecursiveProxy :

info

Il s'agit quasiment de l'implémentation exacte utilisée en production, à l'exception de certains cas particuliers non gérés ici. Voyez par vous-même !

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;
}

Cela semble un peu magique, comment cela fonctionne-t-il ?

  • La méthode get gère les accès aux propriétés comme post.byId. La clé (key) correspond au nom de la propriété accédée : lorsque nous tapons post, notre key sera post, et pour post.byId ce sera byId. Le proxy récursif combine ces clés en un chemin final, par exemple ["post", "byId", "query"], que nous utiliserons pour déterminer l'URL à interroger.

  • La méthode apply est appelée lorsque nous invoquons une fonction sur le proxy, comme .query(args). Les args représentent les arguments passés à la fonction : lors de l'appel post.byId.query(args), nos args seront notre input, que nous transmettrons comme paramètres de requête ou corps de requête selon le type de procédure. Le createRecursiveProxy prend une fonction de rappel que nous mapperons à apply avec le chemin et les arguments.

Voici une représentation visuelle du fonctionnement du proxy lors de l'appel trpc.post.byId.query({ id: 1 }) :

proxy

🧩 Assemblage final

Maintenant que nous avons cet utilitaire et comprenons son fonctionnement, utilisons-le pour créer notre client. Nous fournirons au createRecursiveProxy une fonction de rappel qui prendra le chemin et les arguments pour requêter le serveur via fetch. Nous ajouterons un générique à la fonction acceptant n'importe quel type de routeur tRPC (AnyTRPCRouter), puis nous casterons le type de retour vers le type DecorateRouterRecord créé précédemment :

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

Notons particulièrement que notre chemin utilise des . comme séparateurs plutôt que des /. Cela nous permet d'avoir un unique gestionnaire d'API côté serveur traitant toutes les requêtes, et non un par procédure. Si vous utilisez un framework avec routage basé sur les fichiers comme Next.js, vous reconnaîtrez peut-être le fichier catchall /api/trpc/[trpc].ts qui correspondra à tous les chemins de procédures.

Nous avons également une annotation de type TRPCResponse sur la requête fetch. Cela définit le format de réponse conforme à JSONRPC utilisé par le serveur. Vous pouvez en lire davantage ici. En bref : nous recevons soit un objet result soit un objet error, que nous pouvons utiliser pour déterminer si la requête a réussi et gérer les erreurs appropriées en cas de problème.

Et voilà ! C'est tout le code nécessaire pour appeler vos procédures tRPC côté client comme s'il s'agissait de fonctions locales. En surface, on dirait que nous appelons simplement la fonction de résolution de publicProcedure.query / mutation via des accès normaux aux propriétés, mais nous traversons en réalité une frontière réseau, ce qui nous permet d'utiliser des bibliothèques côté serveur comme Prisma sans exposer les identifiants de base de données.

Testons-le !

Maintenant, créez le client en fournissant l'URL de votre serveur : vous obtiendrez une autocomplétion complète et une sécurité typographique lorsque vous appellerez vos procédures !

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; }

Le code complet du client est disponible ici, et les tests d'utilisation ici.

Conclusion

J'espère que vous avez apprécié cet article et découvert le fonctionnement interne de tRPC. Vous ne devriez probablement pas utiliser cette version minimaliste au détriment de @trpc/client qui ne pèse que quelques kilo-octets de plus - il offre bien plus de flexibilité que ce que nous montrons ici :

  • Options de requête pour signaux d'annulation, SSR, etc...

  • Liens (Links)

  • Regroupement de procédures (Batching)

  • WebSockets / abonnements

  • Gestion d'erreurs élégante

  • Transformateurs de données

  • Gestion des cas limites comme les réponses non conformes à tRPC

Nous n'avons pas non plus abordé en détail les aspects serveur aujourd'hui - nous les couvrirons peut-être dans un futur article. Si vous avez des questions, n'hésitez pas à me contacter sur Twitter.