Aller au contenu principal

Leçons de performance TypeScript lors du refactoring pour la v10

· 10 min de lecture
Sachin Raja
Sachin Raja
tRPC Core Team Member (alumni)
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 →

En tant qu'auteurs de bibliothèques, notre objectif est d'offrir la meilleure expérience développeur (DX) possible à nos pairs. Réduire le délai de détection des erreurs et fournir des API intuitives allège la charge mentale des développeurs, leur permettant de se concentrer sur l'essentiel : une excellente expérience utilisateur final.

Ce n'est un secret pour personne que TypeScript est le moteur derrière la remarquable DX que tRPC propose. L'adoption de TypeScript est aujourd'hui la norme moderne pour offrir des expériences JavaScript de qualité - mais cette certitude accrue autour des types comporte certains compromis.

Actuellement, le vérificateur de types TypeScript a tendance à ralentir (même si des versions comme TS 4.9 sont prometteuses !). Les bibliothèques contiennent presque toujours les incantations TypeScript les plus sophistiquées de votre codebase, poussant votre compilateur TS à ses limites. Pour cette raison, les auteurs de bibliothèques comme nous doivent être conscients de leur contribution à cette charge et faire leur possible pour maintenir votre IDE aussi rapide que possible.

Automatiser la performance des bibliothèques

Alors que tRPC était en v9, nous avons commencé à recevoir des rapports de développeurs indiquant que leurs grands routeurs tRPC affectaient négativement leur vérificateur de types. C'était une nouvelle expérience pour tRPC alors que nous observions une adoption massive durant la phase v9 de son développement. Avec davantage de développeurs créant des produits toujours plus grands avec tRPC, certaines faiblesses sont apparues.

Votre bibliothèque n'est peut-être pas lente actuellement, mais il est crucial de surveiller les performances à mesure qu'elle évolue. Les tests automatisés peuvent alléger considérablement la création de bibliothèques (et le développement d'applications !) en testant programmatiquement votre code bibliothèque à chaque commit.

Chez tRPC, nous faisons notre possible pour garantir cela en générant et en testant un routeur avec 3 500 procédures et 1 000 routeurs. Mais cela ne teste que jusqu'où nous pouvons pousser le compilateur TS avant qu'il ne plante, pas la durée de la vérification de types. Nous testons les trois parties de la bibliothèque (serveur, client vanilla et client React) car elles ont des chemins de code différents. Par le passé, nous avons observé des régressions isolées dans une section de la bibliothèque et comptons sur nos tests pour les détecter. (Nous voulons encore faire plus pour mesurer les temps de compilation)

tRPC n'est pas une bibliothèque gourmande en runtime, donc nos métriques de performance se concentrent sur la vérification de types. Ainsi, nous restons attentifs à :

  • Une lenteur de vérification de types avec tsc

  • Un temps de chargement initial important

  • Un temps de réponse long du serveur de langage TypeScript après des modifications

Ce dernier point est celui auquel tRPC doit accorder le plus d'attention. Vous ne voulez jamais que vos développeurs attendent la mise à jour du serveur de langage après une modification. C'est là que tRPC doit maintenir ses performances pour que vous puissiez profiter d'une excellente DX.

Comment j'ai identifié des opportunités d'amélioration dans tRPC

Il y a toujours un compromis entre la précision TypeScript et les performances du compilateur. Ces deux aspects sont cruciaux pour les autres développeurs, nous devons donc être extrêmement vigilants sur la façon dont nous écrivons nos types. Une application risque-t-elle des erreurs graves parce qu'un type est "trop permissif" ? Le gain de performance en vaut-il la peine ?

Y aura-t-il même un gain de performance significatif ? Excellente question.

Voyons comment repérer les opportunités d'amélioration des performances dans le code TypeScript. Je vais vous détailler le processus que j'ai suivi pour créer la PR #2716, qui a réduit de 59% le temps de compilation TypeScript.


TypeScript propose un outil de traçage intégré pour identifier les goulots d'étranglement dans vos types. Bien qu'imparfait, c'est le meilleur outil disponible.

L'idéal est de tester votre bibliothèque sur une application réelle pour simuler son comportement chez les développeurs. Pour tRPC, j'ai créé une application T3 basique représentative de ce que nos utilisateurs utilisent.

Voici les étapes que j'ai suivies pour tracer tRPC :

  1. Lier localement la bibliothèque à l'application exemple. Cela permet de modifier le code de la bibliothèque et tester immédiatement les changements.

  2. Exécuter cette commande dans l'application exemple :

    sh
    tsc --generateTrace ./trace --incremental false
    sh
    tsc --generateTrace ./trace --incremental false
  3. Un fichier trace/trace.json sera généré. Vous pouvez l'ouvrir dans une application d'analyse de traces (Perfetto ou chrome://tracing).

C'est là que cela devient intéressant pour analyser le profil de performance des types. Premier résultat du traçage : trace bar showing that src/pages/index.ts took 332ms to type-check

Une barre plus longue indique un processus plus long. La barre verte sélectionnée montre que src/pages/index.ts est le goulot d'étranglement. Le champ Duration révèle 332ms - un temps énorme pour la vérification de types ! La barre bleue checkVariableDeclaration indique que le compilateur a passé la majorité du temps sur une variable. En cliquant dessus : trace info showing the variable's position is 275 Le champ pos donne la position de la variable dans le fichier. Dans src/pages/index.ts, le coupable est utils = trpc.useContext() !

Mais comment est-ce possible ? Ce simple hook semble inoffensif ! Examinons le code :

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;

Rien de suspect en surface : juste un useContext et une invalidation de requête. Le problème doit donc être plus profond. Analysons les types sous-jacents :

ts
type DecorateProcedure<
TRouter extends AnyRouter,
TProcedure extends Procedure<any>,
TProcedure extends AnyQueryProcedure,
> = {
/**
* @see 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,
> = {
/**
* @see 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;
}>;

Plusieurs éléments méritent explication. Commençons par comprendre ce code.

Nous avons un type récursif DecoratedProcedureUtilsRecord qui parcourt toutes les procédures du routeur pour les "décorer" (ajouter des méthodes) avec des utilitaires React Query comme invalidateQueries.

Dans tRPC v10, nous maintenons la compatibilité avec les anciens routeurs v9, mais les clients v10 ne peuvent pas appeler les procédures des routeurs v9. Pour chaque procédure, nous vérifions si c'est une procédure v9 (extends LegacyV9ProcedureTag) et la supprimons le cas échéant. C'est un travail conséquent pour TypeScript... s'il n'est pas évalué paresseusement.

Évaluation paresseuse

Le problème ici est que TypeScript évalue tout ce code dans le système de types, même s'il n'est pas utilisé immédiatement. Notre code utilise uniquement utils.r49.greeting.invalidate, donc TypeScript devrait seulement avoir besoin de dérouler la propriété r49 (un routeur), puis la propriété greeting (une procédure), et enfin la fonction invalidate pour cette procédure. Aucun autre type n'est nécessaire dans ce code, et déterminer immédiatement le type de chaque méthode utilitaire React Query pour toutes vos procédures tRPC ralentirait inutilement TypeScript. TypeScript diffère l'évaluation des types pour les propriétés des objets jusqu'à ce qu'elles soient utilisées directement, donc théoriquement notre type ci-dessus devrait bénéficier d'une évaluation paresseuse... n'est-ce pas ?

Eh bien, ce n'est pas exactement un objet. Il y a en réalité un type qui englobe l'ensemble : OmitNeverKeys. Ce type est une utilitaire qui supprime les clés ayant la valeur never d'un objet. C'est à cette étape que nous retirons les procédures v9 pour qu'elles n'apparaissent pas dans l'Intellisense.

Mais cela crée un énorme problème de performance. Nous forçons TypeScript à évaluer les valeurs de tous les types immédiatement pour vérifier s'ils sont never.

Comment résoudre ce problème ? Modifions nos types pour faire moins.

Adoptons la paresse

Nous devons trouver un moyen pour que l'API v10 s'adapte plus harmonieusement aux routeurs hérités v9. Les nouveaux projets tRPC ne devraient pas souffrir de la réduction de performance TypeScript du mode interop.

L'idée est de réorganiser les types fondamentaux. Les procédures v9 sont des entités différentes des procédures v10, elles ne devraient donc pas occuper le même espace dans notre code de bibliothèque. Côté serveur tRPC, cela signifie que nous avons dû stocker les types dans différents champs du routeur plutôt que dans un seul champ record (voir le DecoratedProcedureUtilsRecord ci-dessus).

Nous avons modifié le système pour que les routeurs v9 injectent leurs procédures dans un champ legacy lorsqu'ils sont convertis en routeurs v10.

Anciens types :

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

Si vous vous souvenez du type DecoratedProcedureUtilsRecord ci-dessus, vous verrez que nous avons attaché LegacyV9ProcedureTag ici pour différencier au niveau des types les procédures v9 et v10, et empêcher que les procédures v9 soient appelées depuis des clients v10.

Nouveaux types :

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 */ {}>;

Maintenant, nous pouvons supprimer OmitNeverKeys car les procédures sont pré-triées : le type de la propriété record d'un routeur contiendra toutes les procédures v10, et sa propriété legacy contiendra toutes les procédures v9. Nous ne forçons plus TypeScript à évaluer entièrement l'énorme type DecoratedProcedureUtilsRecord. Nous pouvons aussi supprimer le filtrage des procédures v9 avec LegacyV9ProcedureTag.

Est-ce que ça a fonctionné ?

Notre nouvelle trace montre que le goulot d'étranglement a été supprimé : Barre de trace montrant que src/pages/index.ts a pris 136ms pour la vérification de types

Une amélioration substantielle ! Le temps de vérification des types est passé de 332ms à 136ms 🤯 ! Cela peut sembler minime dans l'absolu, mais c'est une victoire majeure. 200ms, c'est peu une fois – mais considérez :

  • combien d'autres bibliothèques TS sont dans un projet

  • combien de développeurs utilisent tRPC aujourd'hui

  • combien de fois leurs types se ré-évaluent pendant une session de travail

Cela représente beaucoup de 200ms qui s'accumulent pour former un très gros chiffre.

Nous cherchons toujours plus d'opportunités pour améliorer l'expérience des développeurs TypeScript, que ce soit avec tRPC ou un autre problème TS à résoudre. Mentionnez-moi sur Twitter si vous voulez parler TypeScript.

Un grand merci à Anthony Shew pour son aide dans la rédaction de cet article, et à Alex pour sa relecture !