TypeScript Generics: de la confusión a la confianza
Guía práctica de los generics de TypeScript: de los parámetros de tipo básicos a los conditional types avanzados y los patrones de utility types.

Los generics son el punto donde el conocimiento de TypeScript de la mayoría de los desarrolladores choca con un muro. El concepto básico es simple — «una función que funciona con cualquier tipo» — pero el uso en el mundo real rápidamente escala hacia una sopa de corchetes angulares que se lee como un idioma extranjero. La frustración está justificada. La sintaxis de los generics es densa. Pero la alternativa — duplicar código para cada tipo o recurrir a any — es peor.
La clave para entender los generics es reconocerlos como funciones que operan sobre tipos en lugar de valores. Una vez que ese modelo mental encaja, incluso los generics más complejos se vuelven legibles.
Los fundamentos: parámetros de tipo
Un generic es una variable de tipo. Te permite escribir código que funciona con distintos tipos mientras conserva la información de tipos a lo largo de la operación.
// ❌ Using 'any' — loses type information
function first(arr: any[]): any {
return arr[0];
}
const x = first([1, 2, 3]); // x: any — useless
// ✅ Generic — preserves the input type
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const x = first([1, 2, 3]); // x: number | undefined
const y = first(["a", "b"]); // y: string | undefinedTypeScript infiere T a partir del argumento — rara vez necesitas especificarlo de forma explícita. El tipo fluye a través de la función, manteniendo el contrato entre la entrada y la salida.
Restringir generics
Los generics sin restricciones aceptan cualquier tipo, lo que limita lo que puedes hacer con ellos dentro de la función. Las restricciones (extends) acotan el tipo sin dejar de ser genérico.
// ❌ No constraint — can't access .length
function longest<T>(a: T, b: T): T {
return a.length > b.length ? a : b; // Error: T has no .length
}
// ✅ Constrained — T must have a length property
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length > b.length ? a : b;
}
longest("hello", "hi"); // OK — strings have .length
longest([1, 2], [1, 2, 3]); // OK — arrays have .length
longest(10, 20); // Error — numbers don't have .lengthLas restricciones preservan el tipo específico mientras garantizan capacidades mínimas:
// Extract a property from an object — safely
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Wilfredo", age: 30, admin: true };
const name = getProperty(user, "name"); // string
const age = getProperty(user, "age"); // number
const bad = getProperty(user, "email"); // Error: "email" not in keyof userLa restricción keyof T significa que K solo puede ser una clave válida de T. El tipo de retorno T[K] es el tipo de esa propiedad específica — no una unión de todos los tipos de propiedades.
Interfaces y clases genéricas
Los generics en las interfaces definen contratos que quienes los usan completan con tipos específicos.
interface Repository<T> {
findById(id: string): Promise<T | null>;
findAll(filter: Partial<T>): Promise<T[]>;
create(data: Omit<T, 'id' | 'createdAt'>): Promise<T>;
update(id: string, data: Partial<T>): Promise<T>;
delete(id: string): Promise<void>;
}
interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
class UserRepository implements Repository<User> {
async findById(id: string): Promise<User | null> {
return db.query('SELECT * FROM users WHERE id = $1', [id]);
}
async create(data: Omit<User, 'id' | 'createdAt'>): Promise<User> {
// data has type: { name: string; email: string }
return db.query(
'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
[data.name, data.email]
);
}
// ... implement remaining methods
}Cada implementación de Repository<T> obtiene acceso con seguridad de tipos a la estructura de la entidad. Agregar un campo a User actualiza automáticamente el tipo del parámetro del método create.
Tipos utilitarios: generics incorporados
TypeScript incluye tipos utilitarios que son, en sí mismos, patrones con generics que vale la pena entender.
// Partial<T> — all properties optional
type UserUpdate = Partial<User>;
// { id?: string; name?: string; email?: string; createdAt?: Date }
// Required<T> — all properties required
type StrictConfig = Required<Config>;
// Pick<T, K> — select specific properties
type UserPreview = Pick<User, 'id' | 'name'>;
// { id: string; name: string }
// Omit<T, K> — remove specific properties
type CreateUser = Omit<User, 'id' | 'createdAt'>;
// { name: string; email: string }
// Record<K, V> — object with keys of type K and values of type V
type UserMap = Record<string, User>;Estos se combinan de forma natural:
// API response wrapper — reusable across all endpoints
interface ApiResponse<T> {
data: T;
meta: {
timestamp: string;
requestId: string;
};
}
type UserResponse = ApiResponse<User>;
type UserListResponse = ApiResponse<User[]>;
type PaginatedResponse<T> = ApiResponse<T[]> & {
pagination: { page: number; total: number; hasMore: boolean };
};Conditional Types
Los conditional types eligen entre dos tipos según una condición. Siguen el patrón ternario: T extends U ? X : Y.
// Extract the element type from an array, or keep as-is
type Unwrap<T> = T extends Array<infer U> ? U : T;
type A = Unwrap<string[]>; // string
type B = Unwrap<number[][]>; // number[]
type C = Unwrap<boolean>; // boolean
// Exclude null/undefined from a type
type NonNullable<T> = T extends null | undefined ? never : T;
type D = NonNullable<string | null>; // stringLa palabra clave infer introduce una variable de tipo dentro de la condición — «captura» parte del tipo que se está evaluando:
// Extract the return type of a function
type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;
type E = ReturnOf<() => string>; // string
type F = ReturnOf<(x: number) => boolean>; // boolean
// Extract the resolved type from a Promise
type Awaited<T> = T extends Promise<infer R> ? Awaited<R> : T;
type G = Awaited<Promise<string>>; // string
type H = Awaited<Promise<Promise<number>>>; // number (recursive!)Patrones prácticos con generics
Emisor de eventos con seguridad de tipos
type EventMap = {
'user:created': { id: string; name: string };
'user:deleted': { id: string };
'order:placed': { orderId: string; total: number };
};
class TypedEmitter<T extends Record<string, unknown>> {
private listeners = new Map<keyof T, Set<Function>>();
on<K extends keyof T>(event: K, handler: (payload: T[K]) => void): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(handler);
}
emit<K extends keyof T>(event: K, payload: T[K]): void {
this.listeners.get(event)?.forEach((handler) => handler(payload));
}
}
const emitter = new TypedEmitter<EventMap>();
emitter.on('user:created', (data) => {
// data is { id: string; name: string } — fully typed
console.log(data.name);
});
emitter.emit('user:created', { id: '1', name: 'Wilfredo' }); // ✅
emitter.emit('user:created', { id: '1' }); // ❌ Error: missing 'name'Patrón builder con generics
type BuilderState<T> = {
[K in keyof T]?: T[K];
};
class QueryBuilder<T> {
private conditions: string[] = [];
private params: unknown[] = [];
where<K extends keyof T & string>(
field: K,
op: '=' | '>' | '<' | 'LIKE',
value: T[K]
): this {
this.params.push(value);
this.conditions.push(`${field} ${op} $${this.params.length}`);
return this;
}
build(): { sql: string; params: unknown[] } {
const where = this.conditions.length
? `WHERE ${this.conditions.join(' AND ')}`
: '';
return { sql: where, params: this.params };
}
}
const query = new QueryBuilder<User>()
.where('name', 'LIKE', '%Wilfredo%')
.where('age', '>', 25) // ❌ Error if age is string in User
.build();Conclusiones clave
- Los generics son funciones a nivel de tipos — transforman tipos de la misma manera en que las funciones normales transforman valores
- Las restricciones (
extends) son tu red de seguridad — garantizan capacidades mínimas sin perder especificidad - Deja que TypeScript infiera — los argumentos de tipo explícitos rara vez son necesarios cuando la inferencia funciona
- Combina tipos utilitarios —
Pick,Omit,PartialyRecordconstruyen tipos complejos a partir de tipos simples - Los conditional types habilitan lógica a nivel de tipos — usa
inferpara extraer tipos de estructuras complejas - Empieza simple y añade generics cuando aparezca la duplicación — la abstracción prematura en los tipos es tan perjudicial como en el código


