Lecciones de rendimiento en TypeScript al refactorizar para v10
Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →
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.
No es ningún secreto que TypeScript es la fuerza impulsora detrás de la increíble experiencia de desarrollo (DX) que ofrece tRPC. La adopción de TypeScript es el estándar moderno para ofrecer excelentes experiencias basadas en JavaScript hoy en día, pero esta mayor certeza en torno a los tipos conlleva algunas compensaciones.
Hoy, el comprobador de tipos de TypeScript tiende a volverse lento (¡aunque versiones como TS 4.9 son prometedoras!). Las bibliotecas casi siempre contienen los conjuros de TypeScript más sofisticados en tu base de código, llevando tu compilador de TS a sus límites. Por esta razón, los autores de bibliotecas como nosotros debemos ser conscientes de nuestra contribución a esa carga y hacer todo lo posible para que tu IDE siga funcionando lo más rápido posible.
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.
Mientras tRPC estaba en v9, comenzamos a recibir informes de desarrolladores de que sus grandes enrutadores tRPC empezaban a tener efectos perjudiciales en su comprobador de tipos. Esto fue una nueva experiencia para tRPC, ya que vimos una tremenda adopción durante la fase v9 del desarrollo de tRPC. 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 eliminar una carga inmensa de la creación de bibliotecas (¡y de la construcción de aplicaciones!) al probar programáticamente tu código en cada commit.
Para tRPC, hacemos todo lo posible para garantizar esto mediante la generación y pruebas de un enrutador con 3,500 procedimientos y 1,000 enrutadores. Pero esto solo prueba hasta dónde podemos forzar el compilador de TypeScript antes de que falle, no cuánto tiempo 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 detectar estos comportamientos inesperados. (Todavía queremos hacer más para medir los tiempos de compilación)
-
Lentitud al verificar tipos con tsc
-
Tiempos de carga inicial elevados
-
Si el servidor de lenguaje de TypeScript tarda mucho en responder a cambios
-
Ser lento para la comprobación de tipos usando
tsc
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.
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. ¿Será posible que una aplicación encuentre errores graves porque un tipo determinado es "demasiado flexible"? ¿Vale la pena la ganancia en rendimiento?
¿Va a haber realmente una mejora de rendimiento significativa? Gran pregunta.
Así tracé el rendimiento de tRPC: Echemos un vistazo a cómo encontrar oportunidades para mejoras de rendimiento en código TypeScript. Repasaremos el proceso que seguí para crear PR #2716, lo que resultó en una reducción del 59% en el tiempo de compilación de TS.
slug: typescript-performance-lessons title: Lecciones de rendimiento en TypeScript durante la refactorización para v10 authors: [sachinraja]
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!
