TypeScript Avanzado: Tipos Genericos, Utility Types y Patrones

Lleva tu dominio de TypeScript al siguiente nivel con tipos genéricos, utility types, conditional types y patrones avanzados del mundo real.

Introducción: Más allá de los tipos básicos

TypeScript se ha convertido en el estándar para desarrollo JavaScript a gran escala. La mayoría de desarrolladores conocen los tipos básicos: string, number, interfaces, enums y union types. Sin embargo, el verdadero poder de TypeScript reside en su sistema de tipos avanzado: genéricos, utility types, conditional types, template literal types y mapped types.

Dominar estas features avanzadas te permite crear APIs type-safe que previenen errores en tiempo de compilación, reducir código duplicado mediante tipos reutilizables, y expresar relaciones complejas entre datos que serían imposibles de capturar con tipos básicos. Este artículo asume que ya tienes experiencia con TypeScript básico y se centra en las features que separan a un usuario intermedio de un experto.

El sistema de tipos de TypeScript es Turing-completo, lo que significa que teóricamente puedes computar cualquier cosa con él. En la práctica, el objetivo es usar el poder justo que necesitas para hacer tu código más seguro y mantenible, sin caer en la sobre-ingeniería de tipos que dificulta la lectura y el debugging.

Tipos Genéricos: Reutilización con type-safety

Los genéricos son el pilar fundamental del TypeScript avanzado. Permiten crear funciones, clases e interfaces que trabajan con múltiples tipos manteniendo la información de tipo específica. Sin genéricos, tendrías que usar any (perdiendo type-safety) o crear versiones duplicadas de cada función para cada tipo.

Genéricos básicos

Un genérico es un parámetro de tipo, denotado convencionalmente con una letra entre ángulos: <T>. Cuando llamas a una función genérica, TypeScript infiere el tipo concreto basándose en los argumentos, o puedes especificarlo explícitamente. Por ejemplo, una función identity<T>(arg: T): T devuelve exactamente el mismo tipo que recibe: si le pasas un string, devuelve un string; si le pasas un User, devuelve un User.

La inferencia de tipos de TypeScript es lo suficientemente inteligente como para que rara vez necesites especificar los tipos genéricos explícitamente al llamar funciones. El compilador infiere T basándose en los argumentos proporcionados. Solo necesitas especificarlos explícitamente cuando la inferencia no es posible o cuando quieres forzar un tipo específico.

Constraints (restricciones) en genéricos

Las constraints limitan los tipos que un genérico puede aceptar usando la palabra clave extends. Por ejemplo, <T extends { id: string }> restringe T a tipos que tengan al menos una propiedad id de tipo string. Esto te permite acceder a propiedades específicas del tipo genérico con seguridad, mientras mantienes la flexibilidad de aceptar cualquier tipo que cumpla la restricción.

Las constraints son esenciales para crear APIs genéricas que necesitan garantizar ciertas propiedades. Un repositorio genérico Repository<T extends { id: string }> puede implementar operaciones CRUD genéricas sabiendo que T siempre tendrá un id para buscar, actualizar y eliminar.

Múltiples parámetros genéricos

Las funciones pueden tener múltiples parámetros genéricos para expresar relaciones entre diferentes tipos de entrada y salida. Por ejemplo, una función de transformación transform<TInput, TOutput>(input: TInput, transformer: (item: TInput) => TOutput): TOutput captura la relación entre el tipo de entrada y el tipo de salida de la función transformadora.

Un patrón común es usar un genérico para el tipo de datos y otro para el tipo de error: Result<TData, TError>. Esto permite modelar explícitamente tanto el caso de éxito como el de error, forzando al consumidor a manejar ambos casos y eliminando la posibilidad de errores no capturados.

Utility Types: Transformaciones de tipos incorporadas

TypeScript incluye un conjunto de utility types que realizan transformaciones comunes sobre tipos existentes. Estos utility types están implementados usando las features avanzadas del sistema de tipos y son extremadamente útiles en el día a día.

Partial y Required

Partial<T> hace que todas las propiedades de T sean opcionales. Es invaluable para funciones de actualización que aceptan cambios parciales: updateUser(id: string, changes: Partial<User>). El consumidor puede enviar solo los campos que quiere actualizar sin necesidad de proporcionar el objeto completo.

Required<T> es el inverso: hace que todas las propiedades opcionales sean obligatorias. Útil cuando necesitas garantizar que un objeto tiene todas sus propiedades después de aplicar valores por defecto o validación.

Pick y Omit

Pick<T, K> crea un nuevo tipo seleccionando solo las propiedades K del tipo T. Por ejemplo, Pick<User, 'id' | 'name' | 'email'> crea un tipo con solo esas tres propiedades. Ideal para crear tipos de respuesta de API que no deben incluir campos sensibles como contraseñas o tokens.

Omit<T, K> es el complementario: crea un nuevo tipo excluyendo las propiedades K. Por ejemplo, Omit<User, 'password' | 'refreshToken'> crea un tipo User sin los campos sensibles. Es útil para crear tipos derivados sin tener que redefinir todas las propiedades.

Record, Exclude y Extract

Record<K, V> crea un tipo objeto con claves de tipo K y valores de tipo V. Es útil para diccionarios y mapas tipados: Record<string, User> para un mapa de usuarios por ID, o Record<UserRole, Permission[]> para mapear roles a permisos.

Exclude<T, U> elimina de T los tipos que son asignables a U. Por ejemplo, Exclude<'success' | 'error' | 'loading', 'loading'> produce 'success' | 'error'. Es útil para crear subconjuntos de union types.

Extract<T, U> es el inverso: extrae de T solo los tipos asignables a U. Útil para filtrar union types y quedarse solo con los tipos que te interesan.

ReturnType y Parameters

ReturnType<T> extrae el tipo de retorno de una función. Si tienes function fetchUser(): Promise<User>, entonces ReturnType<typeof fetchUser> es Promise<User>. Esto es invaluable para derivar tipos de funciones existentes sin duplicar definiciones.

Parameters<T> extrae los tipos de los parámetros de una función como una tupla. Útil para crear wrappers que necesitan aceptar los mismos parámetros que la función original, garantizando que si la función cambia, el wrapper se actualice automáticamente.

Conditional Types: Lógica en el sistema de tipos

Los conditional types permiten expresar tipos que dependen de una condición, similar a un operador ternario pero en el sistema de tipos. La sintaxis es T extends U ? X : Y: si T es asignable a U, el tipo es X; de lo contrario, es Y.

Un ejemplo práctico: type IsString<T> = T extends string ? true : false. IsString<"hello"> es true, mientras que IsString<42> es false. Aunque este ejemplo es simple, los conditional types se vuelven extremadamente potentes cuando se combinan con genéricos y distribución.

La distribución automática es una feature clave: cuando un conditional type se aplica a un union type, se distribuye sobre cada miembro de la unión. NonNullable<T> está implementado como T extends null | undefined ? never : T, que distribuye sobre cada miembro de la unión y elimina null y undefined.

El keyword infer dentro de conditional types permite extraer tipos de posiciones específicas. Por ejemplo, type UnpackPromise<T> = T extends Promise<infer U> ? U : T extrae el tipo envuelto por una Promise: UnpackPromise<Promise<User>> es User. Esto permite crear utility types que "desempaquetan" tipos complejos.

Template Literal Types: Tipos basados en strings

Los template literal types permiten crear tipos string mediante composición, similar a los template literals de JavaScript pero en el sistema de tipos. Esto abre posibilidades increíbles para tipar APIs con convenciones de nomenclatura específicas.

Por ejemplo, dado un tipo type Color = 'red' | 'green' | 'blue', puedes generar automáticamente todos los variantes de tono: type ColorVariant = `${'light' | 'dark'}-${Color}` produce 'light-red' | 'light-green' | 'light-blue' | 'dark-red' | 'dark-green' | 'dark-blue'. Esto es invaluable para sistemas de diseño con tokens de color.

Otro caso de uso es tipar event handlers: type EventHandler<T extends string> = `on${Capitalize<T>}`. Dado 'click' | 'focus' | 'blur', genera 'onClick' | 'onFocus' | 'onBlur'. Esto permite crear APIs type-safe para sistemas de eventos donde el compilador verifica que los nombres de handlers sigan la convención correcta.

Los template literal types combinados con infer permiten parsear strings en el sistema de tipos. Puedes extraer partes de una ruta URL, descomponer una dirección de email en usuario y dominio, o parsear un string de formato en sus componentes. Aunque estas aplicaciones son avanzadas y deben usarse con moderación, demuestran la potencia del sistema de tipos de TypeScript.

Mapped Types: Transformaciones de objetos

Los mapped types permiten crear nuevos tipos iterando sobre las claves de un tipo existente. La sintaxis { [K in keyof T]: ... } crea un nuevo tipo con las mismas claves que T pero con valores transformados. Todos los utility types como Partial, Required, Pick y Omit están implementados usando mapped types.

Un ejemplo práctico es crear un tipo "Readonly" profundo: type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K] }. Este tipo recursivo hace que todas las propiedades, incluidas las anidadas, sean de solo lectura, algo que el Readonly incorporado de TypeScript no hace.

Los key remapping (con la cláusula as) permiten renombrar claves durante el mapeo. Por ejemplo, puedes crear un tipo que prefije todas las claves: type PrefixGet<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] }. Dado un tipo User con propiedades name y email, genera un tipo con métodos getName() y getEmail().

Patrones del mundo real

Type-safe API client

Define las rutas de tu API y sus tipos de respuesta como un tipo mapeado, y crea un cliente HTTP que infiera automáticamente el tipo de respuesta basándose en la ruta: api.get('/users/:id') devuelve Promise<User> mientras que api.get('/posts') devuelve Promise<Post[]>. Esto elimina la necesidad de especificar tipos manualmente en cada llamada y previene errores de tipo en las respuestas.

Builder pattern type-safe

El patrón builder con genéricos permite construir objetos paso a paso donde el compilador garantiza que no se olvide ningún campo obligatorio. Cada método del builder devuelve un nuevo tipo que refleja los campos ya establecidos, y el método build() solo está disponible cuando todos los campos obligatorios han sido proporcionados.

Discriminated unions exhaustivas

Las discriminated unions (uniones con una propiedad discriminante común) combinadas con la verificación de exhaustividad de TypeScript garantizan que manejes todos los casos posibles. Usa una función assertNever que acepta never como parámetro para que el compilador te avise si añades un nuevo caso a la unión y olvidas manejarlo en algún switch/if.

Type-safe event emitter

Define un mapa de eventos donde cada nombre de evento se asocia con el tipo de su payload. El event emitter resultante es completamente type-safe: emitter.on('userCreated', (payload) => ...) infiere automáticamente que payload es de tipo User, mientras que emitter.on('orderCompleted', (payload) => ...) infiere que payload es de tipo Order.

Conclusión

El sistema de tipos avanzado de TypeScript es una herramienta poderosa que, usada con criterio, puede eliminar categorías enteras de bugs y hacer tu código más expresivo y mantenible. Los genéricos son la base sobre la que se construyen todas las demás features, y dominarlos es esencial antes de aventurarse en conditional types o template literal types.

Sin embargo, recuerda que el objetivo es escribir código claro y mantenible, no demostrar dominio del sistema de tipos. Un tipo que nadie en tu equipo puede entender es peor que un tipo simple. Usa las features avanzadas cuando resuelvan un problema real y mejoren la experiencia de desarrollo, no como un ejercicio académico. El mejor TypeScript es el que tu equipo puede leer, entender y mantener a largo plazo.