TypeScript Generics a fondo: de la confusión al dominio total (con patrones del mundo real)
Los generics son la feature más poderosa del sistema de tipos de TypeScript — y la más malentendida. Si alguna vez te quedaste mirando un type signature como <T extends Record<string, unknown>, K extends keyof T> y sentiste que tu cerebro se cortocircuitaba, no estás solo.
La realidad: los generics no son complicados. Son simplemente funciones para tipos. Una vez que eso hace click, todo lo demás cae en su lugar. Esta guía te va a llevar de "más o menos entiendo generics" a "puedo escribir librerías type-safe" en una sentada.
Qué son los Generics realmente (el modelo mental de 60 segundos)
Un generic es una variable de tipo — un placeholder para un tipo que se llena después. Igual que un parámetro de función es un placeholder para un valor.
Función normal:
function identity(value: string): string { return value; }
Esto solo funciona para strings. ¿Y si queremos que funcione para cualquier tipo?
Sin generics — perdés la información de tipo:
function identity(value: any): any { return value; } const result = identity("hello"); // result es 'any' — inútil
Con generics — el tipo fluye:
function identity<T>(value: T): T { return value; } const result = identity("hello"); // result es 'string' ✅ const num = identity(42); // num es 'number' ✅
T es el parámetro de tipo genérico. Cuando llamás identity("hello"), TypeScript infiere que T = string y lleva esa información hasta el tipo de retorno. Escribís la función una vez, y funciona correctamente para cualquier tipo manteniendo type safety.
Este es todo el concepto. Todo lo demás en esta guía se construye sobre esta única idea.
Más allá de lo básico: Generic Constraints
Los generics sin restricciones aceptan cualquier cosa. No siempre es lo que querés.
El problema: demasiado permisivo
function getLength<T>(value: T): number { return value.length; // ❌ Error: Property 'length' does not exist on type 'T' }
TypeScript no sabe que T tiene una propiedad length. Podría ser un number, un boolean, lo que sea.
La solución: keyword extends
function getLength<T extends { length: number }>(value: T): number { return value.length; // ✅ Funciona — T tiene 'length' garantizado } getLength("hello"); // ✅ string tiene length getLength([1, 2, 3]); // ✅ array tiene length getLength(42); // ❌ Error: number no tiene length
El keyword extends agrega un constraint — le dice a TypeScript "T debe ser un tipo que tenga al menos estas propiedades." Pensalo como una interfaz mínima.
Patrón real: API Response Wrapper
interface ApiResponse<T> { data: T; status: number; timestamp: string; } interface User { id: string; name: string; email: string; } interface Product { id: string; title: string; price: number; } type UserResponse = ApiResponse<User>; // { data: User; status: number; timestamp: string; } type ProductResponse = ApiResponse<Product>; // { data: Product; status: number; timestamp: string; }
Una interfaz, infinitamente reutilizable. El campo data es type-safe para cada caso de uso.
Múltiples parámetros de tipo
Los generics pueden tener múltiples parámetros, igual que las funciones pueden tener múltiples argumentos:
function pair<A, B>(first: A, second: B): [A, B] { return [first, second]; } const result = pair("hello", 42); // [string, number]
Patrón real: Event Emitter type-safe
type EventMap = { userLogin: { userId: string; timestamp: Date }; pageView: { url: string; referrer: string }; purchase: { productId: string; amount: number }; }; class TypedEventEmitter<Events extends Record<string, any>> { private handlers: Partial<{ [K in keyof Events]: Array<(payload: Events[K]) => void>; }> = {}; on<K extends keyof Events>( event: K, handler: (payload: Events[K]) => void ): void { if (!this.handlers[event]) { this.handlers[event] = []; } this.handlers[event]!.push(handler); } emit<K extends keyof Events>(event: K, payload: Events[K]): void { this.handlers[event]?.forEach((handler) => handler(payload)); } } const emitter = new TypedEventEmitter<EventMap>(); emitter.on("userLogin", (payload) => { console.log(payload.userId); // ✅ Autocomplete funciona console.log(payload.timestamp); // ✅ Type-safe }); emitter.on("purchase", (payload) => { console.log(payload.amount); // ✅ number }); emitter.emit("pageView", { url: "/home", referrer: "google.com", }); // ✅ La forma del payload se valida
Este patrón elimina una clase entera de bugs de runtime. El nombre del evento y la forma del payload están vinculados a nivel de tipos. Si renombrás un evento o cambiás su payload, TypeScript atrapa cada handler roto en compile time.
Utility Types genéricos: las herramientas de poder de TypeScript
TypeScript viene con varios utility types genéricos que todo dev debería conocer. Están construidos con los mismos patrones que estás aprendiendo.
Partial<T> — Hacer todas las propiedades opcionales
interface User { name: string; email: string; age: number; } function updateUser(id: string, updates: Partial<User>): void { // updates puede tener cualquier combinación de propiedades de User } updateUser("123", { name: "Alice" }); // ✅ updateUser("123", { email: "[email protected]" }); // ✅ updateUser("123", { invalid: true }); // ❌ Error
Pick<T, K> — Seleccionar propiedades específicas
type UserPreview = Pick<User, "name" | "email">; // { name: string; email: string; }
Omit<T, K> — Remover propiedades específicas
type CreateUserDto = Omit<User, "id" | "createdAt">; // Todo de User excepto id y createdAt
Record<K, V> — Crear tipos de objeto
type StatusMap = Record<"active" | "inactive" | "banned", User[]>; // { active: User[]; inactive: User[]; banned: User[]; }
ReturnType<T> — Extraer el tipo de retorno de una función
function createUser() { return { id: "1", name: "Alice", role: "admin" as const }; } type NewUser = ReturnType<typeof createUser>; // { id: string; name: string; role: "admin" }
Cómo funciona Partial por dentro
La implementación real:
type Partial<T> = { [P in keyof T]?: T[P]; };
Esto es un mapped type. Itera sobre cada key P en T, hace cada una opcional (?), y preserva el tipo de valor original (T[P]). Entender esto te desbloquea la habilidad de crear tus propios utility types.
Tipos condicionales: lógica a nivel de tipos
Los tipos condicionales te dejan escribir lógica if/else en el sistema de tipos:
type IsString<T> = T extends string ? "yes" : "no"; type A = IsString<string>; // "yes" type B = IsString<number>; // "no"
Puede parecer académico, pero los tipos condicionales resuelven problemas reales.
Patrón real: API Response Unwrapper
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T; type A = UnwrapPromise<Promise<string>>; // string type B = UnwrapPromise<Promise<number>>; // number type C = UnwrapPromise<string>; // string (no es Promise, se devuelve tal cual)
El keyword infer captura un tipo dentro de un patrón. Acá extrae el tipo interno de un Promise<>.
Patrón real: acceso profundo a propiedades
type NestedValue<T, Path extends string> = Path extends `${infer Key}.${infer Rest}` ? Key extends keyof T ? NestedValue<T[Key], Rest> : never : Path extends keyof T ? T[Path] : never; interface Config { database: { host: string; port: number; credentials: { username: string; password: string; }; }; cache: { ttl: number; }; } type DbHost = NestedValue<Config, "database.host">; // string type DbUser = NestedValue<Config, "database.credentials.username">; // string type CacheTtl = NestedValue<Config, "cache.ttl">; // number
Este tipo de type es lo que alimenta librerías como el _.get() de Lodash o la API basada en paths de tRPC.
Mapped Types: transformar tipos programáticamente
Los mapped types te permiten crear nuevos tipos transformando los existentes:
// Hacer todas las propiedades readonly type Readonly<T> = { readonly [P in keyof T]: T[P]; }; // Hacer todas las propiedades nullable type Nullable<T> = { [P in keyof T]: T[P] | null; }; // Hacer todas las propiedades requeridas type Required<T> = { [P in keyof T]-?: T[P]; };
Patrón real: tipos para estado de formularios
interface UserForm { name: string; email: string; bio: string; } // Generar estado de error para cada campo type FormErrors<T> = { [K in keyof T]?: string; }; // Generar estado de touched para cada campo type FormTouched<T> = { [K in keyof T]: boolean; }; const errors: FormErrors<UserForm> = { email: "Formato de email inválido", }; const touched: FormTouched<UserForm> = { name: true, email: true, bio: false, };
Una interfaz para tus datos del form, y derivás el estado de error y touched automáticamente.
Template Literal Types: manipulación de strings a nivel de tipos
TypeScript puede manipular strings en el sistema de tipos:
type EventName<T extends string> = `on${Capitalize<T>}`; type ClickEvent = EventName<"click">; // "onClick" type FocusEvent = EventName<"focus">; // "onFocus" type SubmitEvent = EventName<"submit">; // "onSubmit"
Patrón real: Route Builder type-safe
type ExtractParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}` ? Param | ExtractParams<`/${Rest}`> : T extends `${string}:${infer Param}` ? Param : never; type Params = ExtractParams<"/users/:userId/posts/:postId">; // "userId" | "postId" function buildUrl<T extends string>( template: T, params: Record<ExtractParams<T>, string> ): string { return Object.entries(params).reduce( (url, [key, value]) => url.replace(`:${key}`, value as string), template as string ); } buildUrl("/users/:userId/posts/:postId", { userId: "123", postId: "456", }); // ✅ buildUrl("/users/:userId/posts/:postId", { userId: "123", // ❌ Error: falta 'postId' });
El sistema de tipos parsea la template string de la URL y te obliga a proveer cada parámetro.
El operador satisfies: validar tipos sin ensancharlos
Introducido en TypeScript 4.9, satisfies valida que un valor cumpla con un tipo sin cambiar su tipo inferido:
type Color = "red" | "green" | "blue"; type ColorMap = Record<Color, string | number[]>; // Sin satisfies — el tipo se ensancha const colors1: ColorMap = { red: "#ff0000", green: [0, 255, 0], blue: "#0000ff", }; colors1.red.toUpperCase(); // ❌ Error: podría ser number[] // Con satisfies — tipos literales preservados const colors2 = { red: "#ff0000", green: [0, 255, 0], blue: "#0000ff", } satisfies ColorMap; colors2.red.toUpperCase(); // ✅ TypeScript sabe que es string colors2.green.map((c) => c * 2); // ✅ TypeScript sabe que es number[]
satisfies te da lo mejor de los dos mundos: validación de constraints Y inferencia precisa de tipos.
Buenas prácticas con Generics
1. Nombrá los parámetros de tipo descriptivamente
Letras sueltas están bien para generics simples, pero los complejos necesitan nombres:
// ❌ Difícil de leer function merge<A, B, C>(source: A, override: B, defaults: C): A & B & C; // ✅ Mucho más claro function merge< TSource, TOverride, TDefaults, >(source: TSource, override: TOverride, defaults: TDefaults): TSource & TOverride & TDefaults;
2. No usés generics cuando no los necesitás
// ❌ Generic innecesario — T nunca se usa de forma significativa function greet<T extends string>(name: T): string { return `Hello, ${name}!`; } // ✅ Usá el tipo directamente function greet(name: string): string { return `Hello, ${name}!`; }
Si el parámetro de tipo aparece solo una vez en la firma, probablemente no necesitás un generic.
3. Usá valores por defecto en parámetros genéricos
interface ApiResponse<T = unknown> { data: T; status: number; } const response: ApiResponse = { data: null, status: 200 }; const userResponse: ApiResponse<User> = { data: user, status: 200 };
4. Constraint, no assert
// ❌ Usando 'as' para forzar tipos function getProperty(obj: any, key: string) { return obj[key] as any; } // ✅ Usando generics con constraints function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const user = { name: "Alice", age: 30 }; getProperty(user, "name"); // ✅ Retorna string getProperty(user, "age"); // ✅ Retorna number getProperty(user, "email"); // ❌ Error: "email" no está en keyof user
Errores comunes y cómo corregirlos
Error 1: Over-engineering con demasiados generics
// ❌ Ilegible e innecesario type OverEngineered<T extends object, K extends keyof T, V extends T[K]> = { key: K; value: V; original: T; }; // ✅ Versión más simple que hace lo mismo type PropertyEntry<T extends object> = { [K in keyof T]: { key: K; value: T[K]; original: T }; }[keyof T];
Error 2: Olvidar la distribución en tipos condicionales con unions
Los tipos condicionales se distribuyen sobre unions:
type ToArray<T> = T extends any ? T[] : never; type Result = ToArray<string | number>; // string[] | number[] (NO es (string | number)[])
Si querés prevenir la distribución, envolvé ambos lados en un tuple:
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never; type Result = ToArrayNonDist<string | number>; // (string | number)[]
Error 3: No usar parámetros de tipo const
TypeScript 5.0+ soporta parámetros de tipo const para preservar tipos literales:
// Sin const — los literales se ensanchan function createConfig<T>(config: T) { return config; } const c1 = createConfig({ mode: "production" }); // { mode: string } — ensanchado a string // Con const — los literales se preservan function createConfig<const T>(config: T) { return config; } const c2 = createConfig({ mode: "production" }); // { mode: "production" } — tipo literal preservado ✅
Armando todo: un Query Builder type-safe
Construyamos algo real — un query builder type-safe que combina múltiples patrones genéricos:
interface Schema { users: { id: string; name: string; email: string; age: number; role: "admin" | "user"; }; posts: { id: string; title: string; content: string; authorId: string; published: boolean; }; } type WhereClause<T> = { [K in keyof T]?: T[K] | { gt?: T[K]; lt?: T[K]; eq?: T[K] }; }; class QueryBuilder< TSchema extends Record<string, Record<string, any>>, TTable extends keyof TSchema = keyof TSchema, > { private table: TTable | null = null; private selectedFields: (keyof TSchema[TTable])[] = []; from<T extends keyof TSchema>(table: T): QueryBuilder<TSchema, T> { const qb = new QueryBuilder<TSchema, T>(); (qb as any).table = table; return qb; } select<K extends keyof TSchema[TTable]>( ...fields: K[] ): QueryBuilder<TSchema, TTable> { this.selectedFields = fields; return this; } where(clause: WhereClause<TSchema[TTable]>): QueryBuilder<TSchema, TTable> { return this; } build(): string { const fields = this.selectedFields.length > 0 ? this.selectedFields.join(", ") : "*"; return `SELECT ${fields} FROM ${String(this.table)}`; } } const db = new QueryBuilder<Schema>(); db.from("users") .select("name", "email") .where({ role: "admin", age: { gt: 18 } }); db.from("posts") .select("title", "published") .where({ published: true }); // ❌ Todos estos dan errores de compilación: // db.from("users").select("title"); // 'title' no existe en users // db.from("posts").where({ role: "admin" }); // 'role' no existe en posts
Ese es el poder de los generics. El sistema de tipos garantiza que solo podés seleccionar columnas que existen, filtrar por campos válidos con los tipos correctos, y todo validado en compile time.
Cuándo usar Generics
Los generics brillan cuando:
- Estás escribiendo utilidades reutilizables — funciones, clases o tipos usados con múltiples formas de datos
- Las relaciones de tipo importan — el tipo de salida depende del tipo de entrada
- Estás construyendo APIs — interfaces públicas donde los consumidores pasan sus propios tipos
- Querés eliminar
any— generics son casi siempre la mejor alternativa
Salteá los generics cuando:
- Un tipo concreto alcanza — si solo trabajás con
User, tipealo comoUser - No estás preservando información de tipo — si
Taparece solo en el parámetro pero no en el retorno, probablemente no lo necesitás - La legibilidad sufre — si tus colegas no pueden entender la firma de tipos, simplificalo
Conclusión
Los generics de TypeScript no son una feature separada que bolteás cuando las cosas se complican. Son el mecanismo fundamental que hace funcionar todo el sistema de tipos. Cada Array<T>, cada Promise<T>, cada Record<K, V> — son todos generics.
El modelo mental es simple: los generics son funciones para tipos. Toman inputs de tipo, los procesan a través de constraints y condicionales, y producen outputs de tipo. Una vez que internalizás esto, dejás de memorizar sintaxis y empezás a ver patrones.
Empezá con lo básico: un parámetro de tipo, un constraint. Subí a tipos condicionales y mapped types cuando el problema lo demande. Y siempre preguntate: "¿Alcanza con un tipo concreto?" Si sí, salteá el generic. El mejor código a nivel de tipos es el código más simple que preserva la información que necesitás.
El sistema de tipos de TypeScript no es solo un linter. Es un lenguaje de programación dentro de un lenguaje de programación. Los generics son cómo lo programás.
Explora herramientas relacionadas
Prueba estas herramientas gratuitas de Pockit