Lärdomar om TypeScript-prestanda vid omstrukturering för v10
Denna sida har översatts av PageTurner AI (beta). Inte officiellt godkänd av projektet. Hittade du ett fel? Rapportera problem →
Som biblioteksskapare är vårt mål att erbjuda den bästa möjliga utvecklarexperiensen (DX) för våra kollegor. Genom att minska tiden till fel och erbjuda intuitiva API:er reducerar vi den mentala belastningen för utvecklare så de kan fokusera på det viktigaste: enastående användarupplevelser.
Det är ingen hemlighet att TypeScript är drivkraften bakom hur tRPC levererar sin fantastiska DX. Att använda TypeScript är modern standard för att skapa bra JavaScript-baserade upplevelser idag – men denna ökade typsäkerhet har sina avvägningar.
Idag kan TypeScripts typkontroll lätt bli långsam (även om versioner som TS 4.9 ser lovande ut!). Bibliotek innehåller nästan alltid de mest avancerade TypeScript-konstruktionerna i din kodbas, vilket pressar din TS-kompilator till max. Därför måste biblioteksskapare som oss vara medvetna om vår påverkan på denna belastning och göra vårt bästa för att hålla din IDE så snabb som möjligt.
Automatisera biblioteksprestanda
Medan tRPC var i v9 började vi se rapporter från utvecklare om att deras stora tRPC-routrar började få negativ inverkan på deras typkontroll. Detta var nytt för tRPC eftersom vi såg enorm adoption under v9-fasen av tRPC:s utveckling. När fler utvecklare började bygga allt större produkter med tRPC började vissa brister visa sig.
Ditt bibliotek kanske inte är långsamt just nu, men det är viktigt att hålla koll på prestandan när ditt bibliotek växer och förändras. Automatiserad testning kan avlasta dig avsevärt när du skapar bibliotek (och bygger applikationer!) genom att programmatiskt testa din bibliotekskod vid varje incheckning.
För tRPC strävar vi efter att säkerställa detta genom att generera och testa en router med 3 500 procedurer och 1 000 routrar. Men detta testar bara hur långt vi kan pressa TS-kompilatorn innan den bryter samman, inte hur lång tid typkontrollen tar. Vi testar alla tre delarna av biblioteket (server, vanilla-klient och React-klient) eftersom de har olika kodvägar. Tidigare har vi sett regressioner som isolerats till en specifik del av biblioteket och förlitar oss på våra tester för att upptäcka sådana oväntade beteenden. (Vi vill fortfarande göra mer för att mäta kompileringstider)
tRPC är inte ett körningstungt bibliotek så våra prestandamått fokuserar på typkontroll. Därför är vi medvetna om:
-
Långsam typkontroll med
tsc -
Lång initial laddningstid
-
Om TypeScript-språkservern tar lång tid på sig att svara på ändringar
Den sista punkten är den tRPC måste vara extra uppmärksam på. Du vill aldrig att dina utvecklare ska behöva vänta på att språkservern ska uppdateras efter en ändring. Det är här tRPC måste bibehålla prestanda så att du kan njuta av enastående DX.
Hur jag hittade prestandamöjligheter i tRPC
Det finns alltid en avvägning mellan TypeScript-accuracy och kompilatorprestanda. Båda är viktiga aspekter för andra utvecklare så vi måste vara extremt medvetna om hur vi skriver typer. Finns det risk för allvarliga fel i en applikation om en viss typ är "för lös"? Är prestandavinsten värd det?
Kommer det ens att bli en märkbar prestandavinst? Bra fråga.
Låt oss titta på hur man kan hitta möjligheter till prestandaförbättringar i TypeScript-kod. Vi går igenom processen jag följde för att skapa PR #2716, vilket resulterade i 59% snabbare TS-kompilering.
TypeScript har ett inbyggt spårningsverktyg som hjälper dig hitta flaskhalsar i dina typer. Det är inte perfekt, men det bästa tillgängliga verktyget.
Det är idealiskt att testa ditt bibliotek i en verklig applikation för att simulera vad ditt bibliotek gör för utvecklare. För tRPC skapade jag en grundläggande T3-app som liknar vad många användare arbetar med.
Så här spårade jag tRPC:
-
Länka biblioteket lokalt till exempelappen. Detta gör att du kan ändra bibliotekskod och omedelbart testa ändringar lokalt.
-
Kör detta kommando i exempelappen:
shtsc --generateTrace ./trace --incremental falseshtsc --generateTrace ./trace --incremental false -
Du får en
trace/trace.json-fil. Öppna den i ett spårningsverktyg (jag använder Perfetto) ellerchrome://tracing.
Här blir det intressant - vi kan börja analysera typ-prestandan. Så här såg den första spårningen ut:

Ett längre fält indikerar längre exekveringstid. Jag har valt det gröna fältet i skärmdumpen, vilket visar att src/pages/index.ts är flaskhalsen. Under Duration ser vi att det tog 332ms - lång tid för typkontroll! Det blå checkVariableDeclaration-fältet visar att kompilatorn ägnade mest tid åt en variabel.
Klickar man på fältet ser vi detaljer:
Fältet pos avslöjar variabelns position i filen. När vi går till den positionen i src/pages/index.ts ser vi att boven är utils = trpc.useContext()!
Men hur är detta möjligt? Vi använder ju bara ett enkelt hook! Låt oss titta på koden:
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;
Okej, inte mycket att se här. Bara ett useContext och en query-ogiltigförklaring. Inget som borde vara TypeScript-tungt vid första anblick, vilket indikerar att problemet ligger djupare i stacken. Låt oss undersöka typerna bakom denna variabel:
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;}>;
Nu har vi saker att analysera. Låt oss först förstå vad denna kod gör.
Vi har en rekursiv typ DecoratedProcedureUtilsRecord som traverserar alla procedurer i routern och "dekorerar" (lägger till metoder på) dem med React Query-verktyg som invalidateQueries.
I tRPC v10 stöder vi fortfarande gamla v9-routrar, men v10-klienter kan inte anropa procedurer från v9-routrar. Så för varje procedur kontrollerar vi om det är en v9-procedur (extends LegacyV9ProcedureTag) och filtrerar bort den i så fall. Detta är mycket arbete för TypeScript...om det inte utvärderas lättjefullt.
Lazy evaluation
Problemet här är att TypeScript utvärderar all denna kod i typsystemet, även om den inte används omedelbart. Vår kod använder bara utils.r49.greeting.invalidate, så TypeScript borde bara behöva packa upp r49-egenskapen (en router), sedan greeting-egenskapen (en procedur), och slutligen invalidate-funktionen för den proceduren. Inga andra typer behövs i den koden och att omedelbart hitta typen för varje React Query-hjälpmetod för alla dina tRPC-procedurer skulle onödigt sakta ner TypeScript. TypeScript skjuter upp typutvärderingen av egenskaper på objekt tills de används direkt, så teoretiskt borde vår typ ovan få lazy evaluation... eller hur?
Nåväl, det är inte exakt ett objekt. Det finns faktiskt en typ som omsluter hela grejen: OmitNeverKeys. Denna typ är ett verktyg som tar bort nycklar med värdet never från ett objekt. Det är här vi skrapar bort v9-procedurerna så dessa egenskaper inte syns i Intellisense.
Men detta skapar ett enormt prestandaproblem. Vi tvingade TypeScript att utvärdera värdena för alla typer nu för att kontrollera om de är never.
Hur fixar vi detta? Låt oss ändra våra typer till att göra mindre.
Bli lat
Vi måste hitta ett sätt för v10-API:et att anpassa sig till de äldre v9-routern mer graciöst. Nya tRPC-projekt borde inte drabbas av den reducerade TypeScript-prestandan i interop-läge.
Tanken är att omstrukturera kärntyperna själva. v9-procedurer är olika entiteter jämfört med v10-procedurer så de borde inte dela samma utrymme i vår bibliotekskod. På tRPC-serversidan innebar detta att vi hade arbete att göra för att lagra typerna på olika fält i routern istället för ett enda record-fält (se DecoratedProcedureUtilsRecord ovan).
Vi gjorde en ändring så att v9-routrar injicerar sina procedurer i ett legacy-fält när de konverteras till v10-routrar.
Gamla typer:
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;}>;
Om du minns DecoratedProcedureUtilsRecord-typen ovan ser du att vi bifogade LegacyV9ProcedureTag här för att skilja på v9- och v10-procedurer på typnivå och framtvinga att v9-procedurer inte anropas från v10-klienter.
Nya typer:
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 */ {}>;
Nu kan vi ta bort OmitNeverKeys eftersom procedurerna är försorterade så en routers record-egenskapstyp kommer innehålla alla v10-procedurer och dess legacy-egenskapstyp kommer innehålla alla v9-procedurer. Vi tvingar inte längre TypeScript att helt utvärdera den enorma DecoratedProcedureUtilsRecord-typen. Vi kan också ta bort filtreringen för v9-procedurer med LegacyV9ProcedureTag.
Funkade det?
Vår nya spårning visar att flaskhalsen är borta:

En betydande förbättring! Typkontrolleringstiden gick från 332ms till 136ms 🤯! Det kanske inte verkar mycket i det stora hela men det är en enorm vinst. 200ms är en liten mängd en gång - men tänk på:
-
hur många andra TS-bibliotek som finns i ett projekt
-
hur många utvecklare som använder tRPC idag
-
hur många gånger deras typer omvärderas under ett arbetspass
Det blir många 200ms som lägger upp till ett väldigt stort antal.
Vi letar alltid efter fler möjligheter att förbättra upplevelsen för TypeScript-utvecklare, vare sig det är med tRPC eller ett TS-baserat problem att lösa i ett annat projekt. Pinga mig på Twitter om du vill prata TypeScript.
Tack till Anthony Shew för hjälpen med att skriva detta inlägg och till Alex för granskningen!
