Inicio / Artículos / Explicación de TypeScript 6.0: Cambios en el compilador y dominio de los genericos

Explicación de TypeScript 6.0: Cambios en el compilador y dominio de los genericos

Explica en detalle los cambios en el compilador de transición de TypeScript 6.0 y muestra cómo aplicar genéricos para crear código a nivel de tipos más seguro y reutilizable.

2691 palabras

TypeScript sigue evolucionando en dos direcciones al mismo tiempo: el propio compilador se está haciendo más robusto y rápido, mientras que la herramienta más poderosa del sistema de tipos —los genericos— sigue siendo clave para escribir código que permanezca seguro a medida que escala. Comprender los cambios ocultos en TypeScript 6.0 y dominar los genericos le brinda una visión clara de cómo escribir TypeScript que sea a la vez compatible con el futuro y verdaderamente reutilizable. Comience por los cambios en el compilador, ya que establecen la base sobre la cual funcionará cualquier códigobase con uso intensivo de genericos.

Una versión transitoria en el camino hacia un compilador nativo

TypeScript 6.0 no es principalmente una versión con nuevas funcionalidades dirigida a ustedes; es un puente. El equipo de TypeScript lo ha descrito explícitamente como una versión transitoria: la última versión basada en el código original de JavaScript antes de que TypeScript 7.0 se lance como una reescritura completa nativa en Go. Si su actualización a 6.0 parece inusualmente silenciosa, eso es intencionado. La mayoría de los cambios significativos se han reservado para 7.0.

¿Qué cambia realmente?

Varias modificaciones concretas llegan en 6.0:

  • El modo estricto ahora es el predeterminado para nuevos proyectos. Ya no es necesario especificar "strict": true; en su lugar, los proyectos que dependen de un tipado flexible deben establecer explícitamente "strict": false. La intención del equipo es clara: quieren que ustedes resuelvan los problemas de tipo subyacentes en lugar de suprimirlos.
  • Por defecto, el array types está vacío. Antes, TypeScript cargaba automáticamente cada paquete ubicado en node_modules/@types. Ahora no se carga nada a menos que se especifique explícitamente, lo cual puede reducir significativamente los tiempos de compilación en proyectos más grandes.
  • target y module tienen como valor por defecto es2025 y esnext respectivamente. Generar salida en el formato legado ES5 es prácticamente obsoleto.
  • Llegan los tipos de la API Temporal nativa, lo que permite manejar fechas y horas de forma estática y segura desde el punto de vista tipológico, sin necesidad de recurrir a bibliotecas como date-fns o Luxon. Esta es la característica principal que los desarrolladores venían solicitando.
  • // Before: juggling Date math and timezone offsets manually
    const deadline = new Date(Date.now() + 86400000);
    
    // TypeScript 6.0: Temporal makes intent explicit
    const now = Temporal.Now.zonedDateTimeISO("Asia/Kolkata");
    const deadline = now.add({ hours: 24 });
    
    • La inferencia mejora para las funciones que no utilizan this, y ahora TypeScript admite importaciones de subrutas con prefijo # además de combinar moduleResolution: bundler con module: commonjs, una combinación que anteriormente no era posible.
    • La biblioteca estándar incluye ahora Map.getOrInsert, Map.getOrInsertComputed y una función integrada RegExp.escape(), lo que elimina la necesidad de utilidades personalizadas para escapar caracteres.
    • --baseUrl está obsoleto. Migre los alias de rutas a paths en su tsconfig antes de que baseUrl sea eliminado por completo en la versión 7.0.

    Por qué es importante el carácter transicional

    Según lo explica el equipo de TypeScript, la razón detrás de este lanzamiento es preparar a los desarrolladores para 7.0, que trae un compilador basado en Go que promete construcciones incrementales 40-60% más rápidas que las actuales. La conclusión práctica es que este lanzamiento constituye una tarea para usted: resuelva ahora sus advertencias de obsoletud, ya que 7.0 llegará como una mejora de rendimiento gratuita en lugar de una migración disruptiva, especialmente en entornos modernos de Node.js.

    Qué hacer antes de que llegue 7.0

    • Ejecute tsc --init y revise los nuevos errores de modo estricto que se muestran desde el principio.
    • Mueva la configuración de baseUrl a paths ahora, antes de que sea eliminada.
    • Declarar explícitamente su array types en lugar de depender del cargado automático.
    • Comience a introducir Temporal en rutas de código de bajo riesgo o de uso general para familiarizarse con él.

    TypeScript tiene una larga historia de convertir las mejores prácticas actuales en la configuración predeterminada del futuro. La versión 6.0 representa el período de transición tranquila antes de que llegue ese futuro a velocidad nativa; úsela para organizar su configuración de manera que, cuando aparezca la 7.0, la transición apenas se note.

    De la disciplina en la configuración al pensamiento a nivel de tipos

    Las actualizaciones de versión y las opciones del compilador son solo la mitad de la historia para escribir TypeScript de calidad. La otra mitad consiste en saber cómo estructurar sus propios tipos para que el compilador pueda realmente ayudarlo; y eso nos lleva a los genericos, sin duda la característica que separa a los desarrolladores que luchan contra el sistema de tipos de aquellos que lo utilizan con fluidez.

    Casi todos los desarrolladores de TypeScript llegan al mismo punto crítico en su camino. Al principio, el lenguaje parece un bibliotecario meticuloso que vigila por encima de tu hombro: escribes una interfaz para User, otra para Product, y otra para BlogPost, y todo se mantiene ordenado y seguro.

    Luego, la base de código crece.

    Necesitas una función para obtener un User desde una API, luego otra para Product, y otra más para BlogPost. O intentas abreviar el proceso creando un wrapper compartido, te sumerges en errores de compilación y, al final, utilizas any por todas partes solo para que desaparezcan los errores rojos, sin darte cuenta de que así destruyes la red de seguridad que TypeScript estaba destinado a ofrecerte.

    Este es exactamente el obstáculo que frena a muchos desarrolladores. Superarlo implica comprender realmente los generics.

    Los genéricos no son un truco sintáctico que hay que memorizar para una entrevista. Son la base estructural del código que es reutilizable, mantenible y escalable. Una vez que se comprende el concepto, deja de escribir manualmente código repetitivo y comienzas a diseñar sistemas como lo haría un ingeniero experimentado.

    Construyendo el modelo mental adecuado

    Olvídate por un momento del enfoque formal de la informática y piensa en cómo se comporta una función ordinaria de JavaScript. Nunca se escribe un valor específico directamente dentro del cuerpo de la función:

    // Hardcoded: Only works for one specific person
    function greetRahul() {
      return "Hello, Rahul!";
    }
    
    // Dynamic: Uses a parameter as a placeholder for data
    function greet(name: string) {
      return `Hello, ${name}!`;
    }
    

    El parámetro name no es más que un sustituto de un valor que se proporcionará posteriormente, en el momento de la llamada.

    Un genérico funciona exactamente de la misma manera, solo que en lugar de representar un valor, representa un tipo. Las funciones, clases e interfaces pueden aceptar tipos como argumentos, de la misma forma en que las funciones comunes aceptan valores como argumentos.

    Imagínese una caja de cartón para envíos. En la fábrica, nadie sabe aún si al final contendrá una computadora portátil, un par de zapatos o una taza de cerámica; es simplemente un contenedor genérico, Box<T>. Si se coloca una computadora portátil dentro, se convierte en Box<Laptop>; si se colocan zapatos, se convierte en Box<Shoes>. La caja en sí es indiferente a su contenido, pero siempre se sabe qué hay adentro: al abrir una Box<Laptop> se sabe que se puede encenderla, y al abrir una Box<Shoes> se sabe que se pueden usar. No queda nada al azar.

    La eliminación del problema de duplicación mediante genéricos

    Imaginemos una utilidad que envuelve un conjunto de datos junto con metadatos como una marca de tiempo y un ID generado. Sin genéricos, uno termina escribiendo un wrapper casi idéntico para cada modelo en la aplicación:

    // The Brute-Force Approach: Duplicate functions for every entity
    interface User {
      name: string;
      role: string;
    }
    
    interface Product {
      title: string;
      price: number;
    }
    
    function wrapUser(item: User) {
      return {
        id: crypto.randomUUID(),
        createdAt: new Date(),
        data: item,
      };
    }
    
    function wrapProduct(item: Product) {
      return {
        id: crypto.randomUUID(),
        createdAt: new Date(),
        data: item,
      };
    }
    

    Eso constituye una violación directa del principio DRY: veinte modelos de datos significan veinte funciones de envoltura casi idénticas.

    La solución rápida y tentadora es utilizar any para eliminar la duplicación:

    function wrapItem(item: any) {
      return {
        id: crypto.randomUUID(),
        createdAt: new Date(),
        data: item,
      };
    }
    
    const wrapped = wrapItem({ name: "Alex", role: "Admin" });
    
    // TypeScript has no idea what 'wrapped.data' is!
    // Autocomplete is dead. Typos will crash in production.
    console.log(wrapped.data.nonExistentProperty); // Compiles without error, fails at runtime!
    

    El compilador deja de emitir errores, pero se paga ese silencio con la pérdida de seguridad tipológica, autocompletado y refactoring seguro: precisamente las cosas que existe TypeScript para ofrecer.

    La mejor alternativa es expresar la misma utilidad de forma genérica:

    function wrapItem<T>(item: T) {
      return {
        id: crypto.randomUUID(),
        createdAt: new Date(),
        data: item,
      };
    }
    

    La sintaxis <T> realiza tres cosas al mismo tiempo. Primero, declara una variable de tipo llamada T que esta función utilizará. Segundo, al escribir el parámetro como (item: T), significa que el tipo del argumento será el mismo que tenga T cuando se ejecute la función. Tercero, como el tipo de retorno también hace referencia a T, el tipo exacto de la entrada pasa directamente a la salida, en este caso como data: T.

    const userResult = wrapItem({ name: "Alex", role: "Admin" });
    
    // TypeScript automatically infers that T is { name: string; role: string }
    console.log(userResult.data.name); // Full autocomplete works!
    console.log(userResult.data.invalidProp); // Error: Property 'invalidProp' does not exist!
    

    Al llamar a esta función con un objeto de tipo User, TypeScript infiere automáticamente el valor de T —sin necesidad de anotaciones— y mantiene esa estructura inferida en toda la propiedad data devuelta. De este modo, userResult.data.name cuenta con autocompletado completo y verificación de tipos, en lugar de la confianza ciega que te impondría el uso de any.

    Agregar límites con restricciones

    Dejar T completamente sin restricciones funciona bien para ayudantes de tipo identidad genéricos, pero muchas funciones reales necesitan asumir algo sobre la estructura de sus entradas. En lugar de aceptar literalmente cualquier cosa, a menudo se desea indicar “cualquier tipo, siempre y cuando tenga esta forma”. Eso es lo que ofrecen las restricciones genéricas, utilizando la palabra clave extends para delimitar qué puede ser T.

    Considere una función cuyo propósito es imprimir el ID único de una entidad:

    // This causes a compiler error!
    function printId<T>(entity: T) {
      console.log(entity.id);
      // Error: Property 'id' does not exist on type 'T'.
    }
    

    Esto no se compila, porque nada le indica a TypeScript que T tiene un campo id. T podría ser fácilmente un number, un boolean, null o un objeto vacío, ninguno de los cuales garantiza tener el campo .id.

    La solución consiste en restringir T a una forma que incluya un id:

    interface HasId {
      id: string | number;
    }
    
    function printId<T extends HasId>(entity: T) {
      // Safe! TypeScript guarantees entity has an 'id' property.
      console.log(`Entity ID: ${entity.id}`);
      return entity;
    }
    
    // Works perfectly:
    printId({ id: 101, name: "Database Record" });
    printId({ id: "usr_99", email: "dev@example.com" });
    
    // Fails at compile time before hitting production:
    printId({ name: "Unsaved Item" });
    // Error: Argument of type '{ name: string; }' is not assignable to parameter of type 'HasId'.
    

    Escribir T extends HasId le indica al compilador que puede aceptar cualquier tipo, siempre y cuando cumpla con el requisito mínimo de tener una propiedad id.

    Búsquedas seguras de tipo con keyof

    Una causa clásica de errores en JavaScript es acceder a una propiedad que no existe, a menudo debido a un error tipográfico como user.fristName en lugar de user.firstName. Combinar los genericos con el operador keyof permite crear utilidades en las que ese tipo de error sea estructuralmente imposible.

    function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
      return obj[key];
    }
    
    const employee = {
      id: 42,
      name: "Sarah Connor",
      department: "Security",
      isActive: true,
    };
    
    // Autocomplete offers: "id" | "name" | "department" | "isActive"
    const empName = getProperty(employee, "name"); // Type inferred as: string
    const empActive = getProperty(employee, "isActive"); // Type inferred as: boolean
    
    // Typos are caught immediately:
    const badProp = getProperty(employee, "deparment");
    // Error: Argument of type '"deparment"' is not assignable to parameter of type '"id" | "name" | "department" | "isActive"'.
    

    He aquí por qué este patrón es tan poderoso:

    • T representa la estructura del objeto con el que se está trabajando.
  • keyof T genera una unión de todas las claves válidas en T, como por ejemplo "id" | "name" | "department" | "isActive".
  • K extends keyof T obliga a que key sea una de esas cadenas literales, y nada más.
  • T[K] hace que el tipo de retorno coincida con el tipo de valor exacto almacenado en esa clave.
  • Aunque parece una función auxiliar sencilla, en realidad se trata de un contrato en tiempo de compilación que elimina por completo los errores tipográficos en los nombres de las propiedades.

    Diseño de un cliente API reutilizable

    Más allá de las utilidades aisladas, los genericos realmente son útiles en código a escala de producción. Casi todas las aplicaciones web se comunican con algún backend, y la mayoría de las APIs REST envuelven sus respuestas en un formato JSON consistente:

    {
      "status": "success",
      "statusCode": 200,
      "data": { ... },
      "message": "Operation successful"
    }
    

    En lugar de escribir manualmente un tipo de respuesta separado para cada endpoint, puedes definir un contenedor genérico y reutilizarlo en todas partes:

    // 1. The Generic Contract
    interface ApiResponse<TData> {
      status: "success" | "error";
      statusCode: number;
      data: TData;
      message?: string;
    }
    
    // 2. The Pagination Envelope
    interface PaginatedList<TItem> {
      items: TItem[];
      totalCount: number;
      page: number;
      pageSize: number;
    }
    

    Con ese contrato establecido, el propio cliente HTTP se vuelve notablemente compacto y reutilizable:

    async function fetchApi<T>(url: string): Promise<ApiResponse<T>> {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      return response.json();
    }
    
    // Concrete Domain Models
    interface UserProfile {
      id: string;
      username: string;
      email: string;
    }
    
    interface OrderHistory {
      orderId: string;
      totalAmount: number;
      currency: string;
    }
    
    // Usage Example 1: Fetching a single user
    async function loadUser() {
      const response = await fetchApi<UserProfile>("/api/v1/profile");
    
      // Fully typed:
      console.log(response.data.username);
    }
    
    // Usage Example 2: Fetching a paginated list of orders
    async function loadOrders() {
      const response = await fetchApi<PaginatedList<OrderHistory>>("/api/v1/orders");
    
      // Fully typed nested structures:
      response.data.items.forEach(order => {
        console.log(`Order #${order.orderId}: ${order.totalAmount}`);
      });
    }
    

    Los beneficios son significativos: al no tener que crear una función fetch separada para cada ruta, cada endpoint hereda automáticamente seguridad de tipo completa desde la solicitud hasta la respuesta, además de autocompletado preciso y un mantenimiento mucho más sencillo a largo plazo.

    Incorporación de genéricos en componentes UI reutilizables

    El mismo principio se aplica naturalmente a la capa de componentes. Si construyes interfaces en React, Vue o Web Components simples, es probable que hayas creado un menú desplegable, una tabla o una lista en algún momento. Sin genéricos, estos componentes reutilizables tienden a fallar en cuanto necesitas que manejen diferentes tipos de datos.

    Tomemos como ejemplo un componente de tabla genérico creado en React:

    interface TableProps<T> {
      data: T[];
      renderRow: (item: T, index: number) => React.ReactNode;
      keyExtractor: (item: T) => string | number;
    }
    
    export function GenericTable<T>({ data, renderRow, keyExtractor }: TableProps<T>) {
      return (
        <table>
          <tbody>
            {data.map((item, index) => (
              <tr key={keyExtractor(item)}>
                {renderRow(item, index)}
              </tr>
            ))}
          </tbody>
        </table>
      );
    }
    

    Su uso se ve así:

    interface Customer {
      id: string;
      fullName: string;
      loyaltyPoints: number;
    }
    
    const customers: Customer[] = [
      { id: "c1", fullName: "Elena Rostova", loyaltyPoints: 450 },
      { id: "c2", fullName: "David Miller", loyaltyPoints: 1200 },
    ];
    
    function CustomerList() {
      return (
        <GenericTable
          data={customers}
          keyExtractor={(customer) => customer.id} // customer is inferred as Customer!
          renderRow={(customer) => (
            <>
              <td>{customer.fullName}</td>
              <td>{customer.loyaltyPoints} pts</td>
            </>
          )}
        />
      );
    }
    

    Fíjate que no hay conversión de tipo con as Customer, ni uso de any, y tampoco es necesario adivinar nada. Si un compañero renombra posteriormente fullName a name en la interfaz Customer, TypeScript mostrará de inmediato todos los lugares en la interfaz de usuario que aún necesitan actualizarse.

    Mantener los genéricos legibles: tres pautas

    Los genericos son poderosos, pero ese poder invita al sobrediseño. A veces, los conjuntos de código terminan con monstruosidades como ProcessData<T, Record<string, T, U, V W extends keyof>>. Este estado enredado a menudo se denomina “sopa genérica”, y convierte código que de otro modo sería simple en un acertijo del que nadie quiere ocuparse.

    Tres hábitos ayudan a mantener los genericos legibles en lugar de enredados. Un patrón anticomún que merece atención es introducir un parámetro de tipo que solo aparece una vez en la firma de la función:

    // ❌ OVER-ENGINEERED: T is only used once
    function logMessage<T extends string>(message: T): void {
      console.log(message);
    }
    
    // ✅ CLEAN & DIRECT: No generic required
    function logMessage(message: string): void {
      console.log(message);
    }
    

    Si un parámetro de tipo se utiliza solo una vez, generalmente no justifica su complejidad y a menudo puede reemplazarse por un tipo concreto.

    Conclusión: Un cambio de mentalidad

    Escribir código que funcione para un tipo de datos específico te convierte en desarrollador. Escribir código que siga siendo reutilizable, componible y seguro desde el punto de vista tipológico en cualquier tipo de datos es lo que distingue a un desarrollador senior.

    Los genericos te alejan del código repetitivo y frágil hacia arquitecturas que son flexibles y resistentes por diseño. La próxima vez que te encuentres duplicando una interfaz, clonando una función auxiliar o recurriendo a any, detente y pregúntate si ese valor podría convertirse en un parámetro de tipo. Una vez que este instinto se vuelva automático, dejarás de limitarte a escribir TypeScript más rápido y comenzarás a construir sistemas prácticamente inmunes a sorpresas en tiempo de ejecución.

    Lecturas relacionadas