Leçons de performance en TypeScript lors du refactoring pour la v10
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 →
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.
Il n'est un secret pour personne que TypeScript est la force motrice derrière la manière dont tRPC fournit son incroyable expérience développeur (DX). L'adoption de TypeScript est la norme moderne pour offrir aujourd'hui d'excellentes expériences basées sur JavaScript - mais cette certitude accrue autour des types comporte certains compromis.
Aujourd'hui, le vérificateur de type TypeScript a tendance à devenir lent (même si les versions comme TS 4.9 sont prometteuses !). Les bibliothèques contiennent presque toujours les incantations TypeScript les plus sophistiquées de votre base de code, poussant votre compilateur TS à ses limites. Pour cette raison, les auteurs de bibliothèques comme nous doivent être conscients de notre contribution à cette charge et faire de notre mieux pour que votre IDE continue de fonctionner aussi rapidement que possible.
Automatisation des performances de la bibliothèque
Alors que tRPC était en v9, nous avons commencé à recevoir des rapports de développeurs indiquant que leurs grands routeurs tRPC commençaient à avoir des effets néfastes sur leur vérificateur de types. C'était une nouvelle expérience pour tRPC car nous avons constaté une adoption massive pendant la phase v9 de son développement. Avec de plus en plus de développeurs créant des produits de plus en plus grands avec tRPC, des failles ont commencé à apparaître.
Votre bibliothèque n'est peut-être pas lente pour l'instant, mais il est important de surveiller les performances à mesure que votre bibliothèque évolue et change. Les tests automatisés peuvent vous soulager d'un immense fardeau lors de la création de bibliothèques (et de la construction d'applications !) en testant programmatiquement le code de votre bibliothèque à chaque commit.
Pour tRPC, nous faisons de notre meilleur 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 casse, et non 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 toutes des chemins de code différents. Par le passé, nous avons observé des régressions isolées à une section de la bibliothèque et nous comptons sur nos tests pour nous alerter de ces comportements inattendus. (Nous voulons encore faire plus pour mesurer les temps de compilation)
tRPC n'est pas une bibliothèque lourde en temps d'exécution, donc nos métriques de performance sont centrées sur la vérification de types. Par conséquent, nous restons attentifs à :
-
Un temps de vérification des types important avec
tsc -
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.
Y aura-t-il même un gain de performances significatif ? Excellente question.
Voyons comment identifier des opportunités d'amélioration des performances dans du code TypeScript. Nous allons examiner le processus que j'ai suivi pour créer PR #2716, ce qui a entraîné une réduction de 59% du temps de compilation TypeScript.
slug: typescript-performance-lessons title: Leçons de performance TypeScript lors du refactoring pour la v10 authors: [sachinraja]
TypeScript dispose d'un outil de traçage intégré qui peut vous aider à identifier les goulots d'étranglement dans vos types. Il n'est pas parfait, mais c'est le meilleur outil disponible.
Il est idéal de tester votre bibliothèque sur une application réelle pour simuler ce que votre bibliothèque fait pour de vrais développeurs. Pour tRPC, j'ai créé une application T3 de base qui ressemble à ce que beaucoup de nos utilisateurs utilisent.
Voici les étapes que j'ai suivies pour tracer tRPC :
-
Lier localement la bibliothèque à l'application exemple. Cela permet de modifier le code de la bibliothèque et tester immédiatement les changements.
-
Exécuter cette commande dans l'application exemple :
shtsc --generateTrace ./trace --incremental falseshtsc --generateTrace ./trace --incremental false -
Un fichier
trace/trace.jsonsera généré. Vous pouvez l'ouvrir dans une application d'analyse de traces (Perfetto ouchrome://tracing).
C'est là que cela devient intéressant pour analyser le profil de performance des types. Premier résultat du traçage :

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 :
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 :
tsximport 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;
tsximport 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 :
tstype 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;}>;
tstype 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 :
tsexport type V10Router<TProcedureRecord> = {record: TProcedureRecord;};// convert a v9 interop router to a v10 routerexport type MigrateV9Router<TV9Router extends V9Router> = V10Router<{[TKey in keyof TV9Router['procedures']]: MigrateProcedure<TV9Router['procedures'][TKey]> &LegacyV9ProcedureTag;}>;
tsexport type V10Router<TProcedureRecord> = {record: TProcedureRecord;};// convert a v9 interop router to a v10 routerexport 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 :
tsexport type V10Router<TProcedureRecord> = {record: TProcedureRecord;// by default, no legacy procedureslegacy: {};};export type MigrateV9Router<TV9Router extends V9Router> = {// v9 routers inject their procedures into a `legacy` fieldlegacy: {// v9 clients require that we filter queries, mutations, subscriptions at the top-levelqueries: MigrateProcedureRecord<TV9Router['queries']>;mutations: MigrateProcedureRecord<TV9Router['mutations']>;subscriptions: MigrateProcedureRecord<TV9Router['subscriptions']>;};} & V10Router</* empty object, v9 routers have no v10 procedures to pass */ {}>;
tsexport type V10Router<TProcedureRecord> = {record: TProcedureRecord;// by default, no legacy procedureslegacy: {};};export type MigrateV9Router<TV9Router extends V9Router> = {// v9 routers inject their procedures into a `legacy` fieldlegacy: {// v9 clients require that we filter queries, mutations, subscriptions at the top-levelqueries: 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é :

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 !
