Lecciones de rendimiento en TypeScript durante la refactorización para v10
Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →
Como autores de bibliotecas, nuestro objetivo es ofrecer la mejor experiencia de desarrollo (DX) posible a nuestros colegas. Reducir el tiempo para detectar errores y proporcionar APIs intuitivas elimina la carga mental de los desarrolladores, permitiéndoles concentrarse en lo más importante: una gran experiencia para el usuario final.
No es ningún secreto que TypeScript es el motor detrás de la excelente DX que ofrece tRPC. La adopción de TypeScript se ha convertido en el estándar moderno para ofrecer grandes experiencias basadas en JavaScript, aunque esta mayor certeza en los tipos conlleva algunas compensaciones.
Hoy en día, el verificador de tipos de TypeScript tiende a volverse lento (¡aunque versiones como TS 4.9 son prometedoras!). Las bibliotecas casi siempre contienen los encantamientos TypeScript más sofisticados de tu codebase, llevando tu compilador TS al límite. Por esta razón, autores de bibliotecas como nosotros debemos ser conscientes de nuestra contribución a esa carga y esforzarnos por mantener tu IDE funcionando lo más rápido posible.
Automatizando el rendimiento de bibliotecas
Durante la etapa de v9 de tRPC, comenzamos a recibir informes de desarrolladores indicando que sus grandes enrutadores tRPC estaban afectando negativamente a su verificador de tipos. Esta fue una experiencia nueva para tRPC, ya que vimos una adopción masiva durante la fase v9 de su desarrollo. Con más desarrolladores creando productos cada vez más grandes con tRPC, comenzaron a aparecer algunas grietas.
Tu biblioteca puede no ser lenta ahora, pero es importante vigilar el rendimiento a medida que crece y cambia. Las pruebas automatizadas pueden aliviar enormemente la carga de crear bibliotecas (¡y aplicaciones!) al verificar tu código programáticamente en cada commit.
En tRPC, nos aseguramos de esto generando y probando un enrutador con 3.500 procedimientos y 1.000 enrutadores. Pero esto solo prueba hasta dónde podemos llevar el compilador TS antes de que falle, no cuánto tarda la verificación de tipos. Probamos las tres partes de la biblioteca (servidor, cliente vanilla y cliente React) porque todas tienen rutas de código diferentes. En el pasado, hemos visto regresiones aisladas en una sección de la biblioteca y confiamos en nuestras pruebas para mostrarnos cuándo ocurren esos comportamientos inesperados. (Todavía queremos hacer más para medir tiempos de compilación)
tRPC no es una biblioteca pesada en tiempo de ejecución, por lo que nuestras métricas de rendimiento giran en torno a la verificación de tipos. Por lo tanto, mantenemos presente:
-
Lentitud al verificar tipos con
tsc -
Tiempos de carga inicial elevados
-
Si el servidor de lenguaje de TypeScript tarda mucho en responder a cambios
Este último punto es al que tRPC debe prestar más atención. Nunca querrás que tus desarrolladores tengan que esperar a que el servidor de lenguaje se actualice después de un cambio. Aquí es donde tRPC debe mantener el rendimiento para que puedas disfrutar de una gran DX.
Cómo encontré oportunidades de rendimiento en tRPC
Siempre hay un equilibrio entre la precisión de TypeScript y el rendimiento del compilador. Ambas son preocupaciones importantes para otros desarrolladores, por lo que debemos ser extremadamente conscientes de cómo escribimos los tipos. ¿Podría una aplicación encontrar errores graves porque un tipo es "demasiado flexible"? ¿Vale la pena la ganancia de rendimiento?
¿Habrá siquiera una mejora de rendimiento significativa? Excelente pregunta.
Veamos cómo identificar oportunidades para mejorar el rendimiento en código TypeScript. Recorreremos el proceso que seguí para crear el PR #2716, que logró reducir un 59% el tiempo de compilación de TS.
TypeScript incluye una herramienta de trazado que ayuda a identificar cuellos de botella en tus tipos. No es perfecta, pero es la mejor opción disponible.
Es ideal probar tu biblioteca en una aplicación real para simular lo que experimentan los desarrolladores. Para tRPC, creé una aplicación básica con T3 similar a lo que usan muchos de nuestros usuarios.
Así tracé el rendimiento de tRPC:
-
Vincula localmente la biblioteca a la aplicación de ejemplo. Esto permite probar cambios inmediatamente.
-
Ejecuta este comando en la app de ejemplo:
shtsc --generateTrace ./trace --incremental falseshtsc --generateTrace ./trace --incremental false -
Obtendrás un archivo
trace/trace.json. Ábrelo con una herramienta de análisis como Perfetto ochrome://tracing.
Aquí empieza lo interesante. Mi primer trazado mostró esto:

Las barras más largas indican mayor tiempo de proceso. He seleccionado la barra verde superior para esta captura de pantalla, indicando que src/pages/index.ts era el cuello de botella. Bajo el campo Duration, verás que tomó 332ms: ¡una enorme cantidad de tiempo para la verificación de tipos! La barra azul checkVariableDeclaration revela que el compilador se enfocó en una variable. Al inspeccionarla:
El campo pos indica la ubicación de la variable en el archivo. Al ir a esa posición en src/pages/index.ts se revela que el culpable era utils = trpc.useContext()!
¿Pero cómo? ¡Es solo un hook simple! Veamos el código:
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;
Aparentemente nada complejo: solo un useContext y una invalidación de query. El problema debía estar más profundo. Examinemos los tipos:
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;}>;
Tenemos material para analizar. Primero entendamos qué hace este código.
Un tipo recursivo DecoratedProcedureUtilsRecord recorre todos los procedimientos del router y los "decora" (añade métodos) con utilidades de React Query como invalidateQueries.
En tRPC v10 aún soportamos routers v9, pero los clientes v10 no pueden llamar a procedimientos de routers v9. Para cada procedimiento, verificamos si es de v9 (extends LegacyV9ProcedureTag) y lo excluimos si es necesario. ¡Mucho trabajo para TypeScript... si no se evalúa perezosamente!
Evaluación perezosa
El problema aquí es que TypeScript está evaluando todo este código en el sistema de tipos, aunque no se use inmediatamente. Nuestro código solo utiliza utils.r49.greeting.invalidate, por lo que TypeScript solo debería necesitar descomponer la propiedad r49 (un router), luego la propiedad greeting (un procedimiento) y finalmente la función invalidate para ese procedimiento. No se necesitan otros tipos en ese código, y determinar inmediatamente el tipo de cada método de utilidad de React Query para todos los procedimientos de tRPC ralentizaría innecesariamente a TypeScript. TypeScript difiere la evaluación de tipos para propiedades en objetos hasta que se usan directamente, así que teóricamente nuestro tipo debería tener evaluación perezosa... ¿verdad?
Bueno, no es exactamente un objeto. En realidad hay un tipo envolviendo todo: OmitNeverKeys. Este tipo es una utilidad que elimina las claves con valor never de un objeto. Aquí es donde eliminamos los procedimientos de v9 para que esas propiedades no aparezcan en Intellisense.
Pero esto crea un enorme problema de rendimiento. Forzamos a TypeScript a evaluar los valores de todos los tipos ahora para verificar si son never.
¿Cómo podemos solucionarlo? Cambiemos nuestros tipos para hacer menos.
Hazlo perezoso
Necesitamos que la API de v10 se adapte a los routers heredados de v9 de forma más elegante. Los nuevos proyectos de tRPC no deberían sufrir el rendimiento reducido de TypeScript en el modo de interoperabilidad.
La idea es reorganizar los tipos principales. Los procedimientos de v9 son entidades diferentes a los de v10, por lo que no deberían compartir el mismo espacio en nuestro código de biblioteca. En el lado del servidor de tRPC, esto significó trabajo para almacenar los tipos en campos diferentes del router en lugar de un solo campo record (ver DecoratedProcedureUtilsRecord mencionado antes).
Implementamos un cambio para que los routers de v9 inyecten sus procedimientos en un campo legacy al convertirse a routers de v10.
Tipos antiguos:
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 recuerdas el tipo DecoratedProcedureUtilsRecord anterior, verás que adjuntamos LegacyV9ProcedureTag aquí para diferenciar entre procedimientos de v9 y v10 a nivel de tipo y garantizar que los procedimientos de v9 no sean llamados desde clientes de v10.
Tipos nuevos:
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 */ {}>;
Ahora podemos eliminar OmitNeverKeys porque los procedimientos están preclasificados: el tipo de propiedad record de un router contendrá todos los procedimientos de v10, y su propiedad legacy contendrá los de v9. Ya no forzamos a TypeScript a evaluar completamente el enorme tipo DecoratedProcedureUtilsRecord. También podemos eliminar el filtrado para procedimientos de v9 con LegacyV9ProcedureTag.
¿Funcionó?
Nuevo seguimiento muestra que el cuello de botella se eliminó:

¡Una mejora sustancial! El tiempo de verificación de tipos bajó de 332ms a 136ms 🤯. Puede parecer poco en perspectiva, pero es una gran victoria. 200ms es una cantidad pequeña una vez, pero considera:
-
cuántas otras bibliotecas TS hay en un proyecto
-
cuántos desarrolladores usan tRPC actualmente
-
cuántas veces se reevalúan sus tipos en una sesión de trabajo
Eso suma muchos 200ms acumulándose en un número muy grande.
Siempre buscamos oportunidades para mejorar la experiencia de desarrolladores TypeScript, ya sea con tRPC o problemas basados en TS en otros proyectos. Mencioname en Twitter si quieres hablar de TypeScript.
¡Gracias a Anthony Shew por ayudar a escribir esta publicación y a Alex por revisarla!
