12k
All articles

Buenas Prácticas de TypeScript para Proyectos a Gran Escala

Buenas prácticas de TypeScript para grandes proyectos: modo estricto, noUncheckedIndexedAccess, exactOptionalPropertyTypes, tipos generados, validación en runtime y CI.

OpenReplay Team
OpenReplay Team
Buenas Prácticas de TypeScript para Proyectos a Gran Escala

A escala, TypeScript solo rinde sus frutos con una consistencia disciplinada: el modo estricto como línea de base, límites explícitos y validados, tipos generados en los bordes para que los equipos no puedan desviarse, y un conjunto reducido de patrones sobre los que todo el equipo realmente está de acuerdo. El lenguaje dejó de ser la parte difícil hace años — la parte difícil es mantener una base de código de un millón de líneas y múltiples colaboradores refactorizable sin que una regresión en la seguridad de tipos se cuele en cada PR. Esta guía cubre las convenciones, los flags del compilador y los patrones arquitectónicos que se sostienen a esa escala, con una ruta de migración para la base de código relajada que probablemente heredaste, y está actualizada a las dos versiones que redefinen el panorama de 2026: TypeScript 6.0 (GA) y 7.0 (Release Candidate).

Puntos Clave

  • A partir de TypeScript 6.0 (lanzado el 23 de marzo de 2026), strict tiene el valor predeterminado true a nivel del compilador, por lo que en una base de código moderna a gran escala, el modo estricto es el punto de partida, no la meta.
  • Los dos flags que realmente marcan la diferencia en una base de código a gran escala no forman parte de strict en absoluto: noUncheckedIndexedAccess y exactOptionalPropertyTypes deben habilitarse explícitamente, y detectan los errores de acceso por índice a arrays y de propiedades opcionales que strict permite silenciosamente.
  • Los tipos generados son la práctica de mayor impacto para una base de código multi-equipo: cuando el frontend y el backend derivan sus tipos de un único esquema OpenAPI o Prisma, ambos lados físicamente no pueden divergir, y la CI falla en el momento en que el contrato cambia.
  • Los tipos estáticos son una promesa en tiempo de compilación, no una verificación en tiempo de ejecución — una respuesta tipada como User solo está afirmada como tal, razón por la cual cada límite externo necesita validación en tiempo de ejecución además de un tipo generado.
  • Microsoft informa que el compilador basado en Go de TypeScript 7.0 es frecuentemente ~10× más rápido que la versión 6.0 en bases de código grandes, y en su Release Candidate de junio de 2026 se distribuye dentro del binario estándar tsc y el paquete typescript.

Disciplina del compilador: el modo estricto es el piso, no el logro

Cualquier artículo superficial de buenas prácticas todavía te dice que “habilites el modo estricto” como si fuera una opción heroica. Ese enfoque ya está obsoleto. Según las notas de la versión de TypeScript 6.0, strict es true por defecto a nivel del compilador — si dependías del antiguo valor predeterminado de false, ahora debes establecer "strict": false explícitamente. TypeScript 6.0 fue anunciado el 23 de marzo de 2026, y está previsto que sea la última versión basada en la base de código JavaScript actual. Por tanto, en cualquier proyecto que actualice su compilador, el modo estricto es la línea de base asumida.

La verdadera mejora para proyectos a gran escala son los dos flags de alto valor que strict no incluye. strict activa aproximadamente nueve verificaciones de seguridad de tipos (noImplicitAny, strictNullChecks y similares), pero omite noUncheckedIndexedAccess, que añade undefined a todo acceso por índice no declarado, y exactOptionalPropertyTypes, que distingue una propiedad establecida como undefined de una propiedad ausente. Estos detectan exactamente los errores que se cuelan en una base de código “strict”: la búsqueda en un array que asume que el elemento existe, y el campo opcional que está presente pero con valor undefined.

Un tsconfig.json versionado para proyectos a gran escala (TypeScript 6.0.x):

{
  "compilerOptions": {
    "strict": true,                      // predeterminado desde 6.0; mantenlo explícito para toolchains más antiguos
    "noUncheckedIndexedAccess": true,    // arr[i] es T | undefined, no T
    "exactOptionalPropertyTypes": true,  // { x?: number } rechaza { x: undefined }
    "verbatimModuleSyntax": true,        // fuerza imports de solo tipo (ver rendimiento de build)
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "composite": true                    // requerido para referencias de proyecto
  }
}

El plan de migración hacia la estrictez incremental

Si heredaste una base de código relajada, no actives todos los flags de golpe — aplica la estrictez directorio por directorio, registra un porcentaje de type-coverage en la CI y sube el umbral progresivamente para que la cobertura nunca pueda retroceder. Una aplicación de 200k líneas con miles de any implícitos no compilará limpiamente el primer día, y un PR con 2.800 errores es imposible de revisar.

Una secuencia realista:

  1. Activa strict: true globalmente pero delimita su aplicación: mantén un tsconfig base permisivo y añade archivos tsconfig.json más estrictos por directorio de funcionalidad usando referencias de proyecto, priorizando los peores infractores.
  2. Añade type-coverage a la CI como trinquete — falla el build si el porcentaje de símbolos tipados cae por debajo del último número registrado. La cobertura puede estancarse, pero nunca retroceder.
  3. Introduce los flags adicionales (noUncheckedIndexedAccess, exactOptionalPropertyTypes) con // @ts-expect-error en las violaciones restantes, y luego ve eliminándolas. @ts-expect-error se auto-reporta cuando una supresión deja de ser necesaria, por lo que el backlog no puede pudrirse silenciosamente.

Diseño de tipos a escala

Los buenos tipos a escala hacen que los estados ilegales sean incompilables y que los errores de dominio sean evidentes. Tres patrones hacen la mayor parte del trabajo; el resto es consistencia.

La regla de interface vs type, enunciada de una vez: usa interface para contratos de objetos públicos y extensibles (admite la fusión de declaraciones y tiende a producir mejores mensajes de error en formas de objetos grandes), y usa type para uniones, intersecciones, tipos mapeados y tipos condicionales. Ese es todo el debate. Elige la regla, aplícala con un linter y sigue adelante.

Haz que los estados imposibles sean irrepresentables

El uso excesivo de flags booleanos es la fuente más común de errores del tipo “esto nunca debería ocurrir” en una base de código de UI a gran escala. El tipo a continuación permite dieciséis combinaciones, la mayoría sin sentido — isLoading e error activos al mismo tiempo, data presente durante un error:

// Anti-patrón: cada campo independiente, estados imposibles permitidos
interface RequestState<T> {
  isLoading: boolean;
  isError: boolean;
  data?: T;
  error?: Error;
}

Una unión discriminada reduce eso exactamente a los estados que pueden ocurrir, y el compilador te obliga a manejar cada uno:

type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function render<T>(state: RequestState<T>) {
  switch (state.status) {
    case "success":
      return state.data;   // data solo existe aquí
    case "error":
      return state.error;  // error solo existe aquí
    // un caso faltante es un error de compilación con una verificación de exhaustividad adecuada
  }
}

Las repeticiones de sesión del estado de solicitud con flags booleanos frecuentemente revelan el modo de fallo que este patrón elimina: una UI que renderiza un spinner y datos desactualizados simultáneamente porque dos booleanos independientes se desincronizaron.

Aplica branding a los IDs de tu dominio

Los tipos con branding convierten UserId y OrderId en tipos incompatibles aunque ambos sean string en tiempo de ejecución, haciendo que sea un error de compilación pasar uno donde se espera el otro:

declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

const asUserId = (s: string) => s as UserId;

function cancelOrder(id: OrderId) { /* ... */ }

const u = asUserId("u_123");
cancelOrder(u); // ❌ Argument of type 'UserId' is not assignable to 'OrderId'

En una firma de función con cinco argumentos de tipo string, el branding es la diferencia entre un error por argumentos transpuestos detectado en tiempo de compilación y uno descubierto en producción.

Prefiere unknown sobre any. any deshabilita el verificador y se propaga silenciosamente; unknown obliga a un paso de narrowing antes de su uso. Prohíbe any en el linter y trata todo valor externo — JSON.parse, bindings de catch, retornos de librerías sin tipos — como unknown hasta que se demuestre lo contrario. Usa as const en configuraciones literales y tablas de búsqueda para que infieran tipos literales estrechos en lugar de primitivos ampliados.

Modela y genera tipos en tus límites

La decisión arquitectónica de mayor impacto en una base de código a gran escala es cómo tipas los bordes. Dos reglas.

Primero, no reutilices un único tipo User a través del wire, la base de datos y la UI — modela la respuesta de la API, el DTO y la entidad de dominio como tres tipos distintos para que un cambio en un límite no pueda corromper silenciosamente otro. La forma que serializa tu backend, la que devuelve tu ORM y la que consumen tus componentes divergen con el tiempo; colapsarlas en un único tipo acopla cada capa con las demás.

Segundo, genera los tipos de los límites en lugar de escribirlos a mano. Cuando el frontend y el backend derivan sus tipos de un único esquema, ambos lados físicamente no pueden divergir, y la CI falla en el momento en que el contrato cambia. Usa openapi-typescript para convertir un documento OpenAPI 3.0/3.1 en tipos sin overhead en tiempo de ejecución, Prisma para tipos derivados de la base de datos, o GraphQL Code Generator para operaciones tipadas. Regenera en la CI y falla si hay diferencias:

# Paso de CI: regenerar y fallar si los tipos confirmados están desactualizados
npx openapi-typescript ./openapi.yaml -o ./src/api/schema.gen.ts
git diff --exit-code ./src/api/schema.gen.ts

Pero un tipo generado sigue siendo solo una promesa en tiempo de compilación. Los tipos estáticos son una promesa en tiempo de compilación, no una verificación en tiempo de ejecución — una respuesta tipada como User solo está afirmada como tal, razón por la cual cada límite externo necesita validación en tiempo de ejecución además de un tipo generado. Valida el payload real con una librería de esquemas como Zod o Valibot y deriva el tipo estático del esquema, de modo que una única definición proteja ambas capas:

import { z } from "zod";

const User = z.object({ id: z.string(), email: z.email() });
type User = z.infer<typeof User>;

const res = await fetch("/api/me");
const user = User.parse(await res.json()); // lanza una excepción si la forma real no coincide

El argumento para validar los límites, no solo tiparlos, es empírico: los tipos estáticos desaparecen en tiempo de ejecución, y la repetición de sesión es una técnica para ver los fallos que los tipos no pueden detectar — el momento en que una respuesta real de la API no coincide con su forma declarada y la UI falla en la sesión de un usuario.

Organiza los tipos para una base de código con múltiples colaboradores

  • Coloca los tipos junto al código que los usa — en el mismo archivo o en un archivo hermano *.types.ts — y reserva un types/index.ts (o un paquete dedicado) únicamente para contratos genuinamente compartidos.
  • Organiza por carpeta de funcionalidad/dominio, no por capa técnica, para que los tipos, componentes y lógica de una funcionalidad convivan y la propiedad sea evidente.
  • En un monorepo, conecta los paquetes con referencias de proyecto y mapeo de paths para que los imports crucen límites de módulo limpios (@org/billing) en lugar de rutas relativas frágiles (../../../), y para que el compilador haga cumplir el grafo de dependencias.

Rendimiento de build en 2026: el compilador nativo cambia el panorama

La conversación sobre el rendimiento de build ha cambiado fundamentalmente, y ya no se trata de ahorrar segundos con flags de tsc. TypeScript anunció el Release Candidate de la versión 7.0 el 18 de junio de 2026; con la velocidad del código nativo y el paralelismo de memoria compartida, es frecuentemente unas 10 veces más rápido que TypeScript 6.0. El benchmark de referencia de Microsoft verificó la base de código de VS Code (~1,5M de líneas) en aproximadamente 7,5 segundos frente a 77,8 con el compilador anterior, aunque el múltiplo es menor en proyectos pequeños.

El aspecto de la distribución es el que hay que entender correctamente. El cambio práctico principal en el RC es el empaquetado: la reescritura basada en Go pasó de un paquete native-preview separado al paquete npm regular de TypeScript, por lo que TypeScript 7.0 ya está listo para pruebas más amplias como el compilador tsc habitual. Instálalo con npm install -D typescript@rc y ejecuta el binario estándar tsc — los paquetes más antiguos tsgo / @typescript/native-preview ahora solo distribuyen compilaciones nocturnas. A finales de junio de 2026, la última versión estable es TypeScript 6.0.3, con la 7.0 en RC y la GA estable prevista aproximadamente un mes después del RC. Considera la versión y el estado como volátiles y vuelve a verificarlos en el momento de la adopción.

Los controles estructurales que puedes gestionar en tu propia configuración siguen valiendo la pena con cualquier compilador: referencias de proyecto para builds incrementales y conscientes de dependencias, e imports de solo tipo forzados por verbatimModuleSyntax para que los símbolos de solo tipo se eliminen y nunca se emitan como imports en tiempo de ejecución. verbatimModuleSyntax (introducido en la versión 5.0) es el enfoque recomendado actualmente; los flags que reemplazó, importsNotUsedAsValues y preserveValueImports, quedaron sin efecto en la versión 5.5 y generan un error al especificarlos a partir de la versión 6.0.

import type { User } from "./user";  // eliminado completamente de la salida JS
import { fetchUser } from "./api";   // import de valor, se conserva

Automatiza las salvaguardas

Las convenciones que no se aplican se degradan. Ejecuta typescript-eslint con reglas que requieren información de tipos (no-explicit-any, no-floating-promises, no-misused-promises) para que los patrones anteriores se verifiquen mecánicamente, no durante la revisión. Deja la aplicación de imports de solo tipo a verbatimModuleSyntax en lugar de a la regla de lint consistent-type-imports — ejecutar ambas es redundante y puede producir errores contradictorios. Ejecuta tsc --noEmit en la CI en cada PR como una puerta de entrada obligatoria, junto con el trinquete de type-coverage del plan de migración. Y mantén una salvaguarda humana por encima de toda la automatización: claridad sobre ingeniosidad. Un tipo condicional y mapeado profundamente anidado que a un ingeniero senior le lleva diez minutos leer es una deuda, no una demostración de habilidad — la mayor parte del código de tipos en proyectos a gran escala debería ser aburrido, legible y obvio.

El hilo conductor es la consistencia, no la sofisticación. Activa los dos flags que strict omite, haz que tus estados ilegales sean incompilables, genera y valida tus límites, y deja que la CI aplique el resto. Toma el tsconfig versionado anterior como tu línea de base esta semana, luego apunta el trinquete de type-coverage a tu directorio más problemático y empieza a subir.

Preguntas Frecuentes

¿Es el modo estricto suficiente para un proyecto TypeScript a gran escala?

No. A partir de TypeScript 6.0, strict ya tiene el valor predeterminado true a nivel del compilador, por lo que es la línea de base en lugar de un logro. Los dos flags que más importan en una base de código a gran escala no pertenecen a la familia strict en absoluto: noUncheckedIndexedAccess, que añade undefined al acceso por índice no declarado, y exactOptionalPropertyTypes, que distingue una propiedad establecida como undefined de una propiedad ausente. Habilita ambos explícitamente y luego añade tipos de límites y validación en tiempo de ejecución.

¿Cuál es la diferencia entre interface y type en TypeScript, y cuándo debería usar cada uno?

Usa interface para contratos de objetos públicos y extensibles porque admite la fusión de declaraciones y tiende a producir mensajes de error más claros en formas de objetos grandes. Usa type para uniones, intersecciones, tipos mapeados y tipos condicionales, que interface no puede expresar. Para un equipo grande, la regla práctica es elegir esta convención una vez, aplicarla con una regla de lint y dejar de debatirla. Ambos compilan a verificaciones de tipo idénticas para formas de objetos simples, por lo que la elección es sobre expresividad y consistencia, no sobre capacidad.

¿Los tipos generados desde OpenAPI o Prisma hacen innecesaria la validación en tiempo de ejecución?

No. Un tipo generado es solo una promesa en tiempo de compilación. Una respuesta JSON tipada como User simplemente está afirmada como coincidente con esa forma; el compilador nunca inspecciona el payload real en tiempo de ejecución, por lo que un cambio en el backend o un campo nulo sigue pasando desapercibido. Los tipos generados evitan que el frontend y el backend diverjan en el contrato, pero aún necesitas una librería de esquemas como Zod o Valibot para validar el payload real en cada límite externo. Deriva el tipo estático del esquema para que una única definición proteja ambas capas.

¿Cómo instalo y ejecuto TypeScript 7.0 en su etapa de Release Candidate?

Instálalo con npm install -D typescript@rc y ejecuta el binario estándar tsc. En el Release Candidate de junio de 2026, el compilador nativo basado en Go salió del paquete native-preview separado y pasó al paquete npm regular de typescript, por lo que ya no existe un binario tsgo distinto para el RC; los paquetes más antiguos tsgo y typescript native-preview ahora solo distribuyen compilaciones nocturnas. Microsoft informa que la versión 7.0 es frecuentemente unas diez veces más rápida que la 6.0 en bases de código grandes. Considera la versión y el estado como volátiles y vuelve a verificarlos antes de adoptarla.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.