Si llevas un tiempo escribiendo TypeScript pero los genéricos aún se sienten como algo que
copias y pegas de Stack Overflow sin entenderlo del todo, este es el artículo que lo cambiará.
Los genéricos son la característica que lleva TypeScript de "JavaScript con anotaciones de tipo" a un
sistema de tipos verdaderamente expresivo. Una vez que hace clic, los verás en todas partes — y los usarás
naturalmente en lugar de recurrir a any cuando las cosas se complican. El
capítulo de genéricos del Manual de TypeScript
cubre la especificación completa; este artículo se enfoca en los patrones que realmente usarás en bases de código reales.
El problema con any
Los genéricos existen para resolver un problema específico: quieres que una función o estructura de datos
funcione con múltiples tipos, pero no quieres desechar la información de tipos en el proceso.
La solución ingenua es any — y parece bien hasta que te das cuenta de lo que has perdido:
// With any — TypeScript has no idea what comes out
function identity(arg: any): any {
return arg;
}
const result = identity('hello');
// result is typed as 'any' — you've lost the string type
// TypeScript won't catch this:
result.toFixed(2); // no error at compile time, crashes at runtimeLa información de tipo entra, pero no sale. Lo que hagas con
result queda completamente sin verificar. Eso no es una base de código TypeScript — es JavaScript
con pasos adicionales. Los genéricos resuelven esto permitiéndote decir: "No sé el tipo exacto todavía, pero
lo que entre debería salir con el mismo tipo."
// With a generic — T flows through the function
function identity<T>(arg: T): T {
return arg;
}
const result = identity('hello');
// result is typed as 'string' ✅
result.toFixed(2); // ❌ TypeScript catches this: Property 'toFixed' does not exist on type 'string'
const count = identity(42);
// count is typed as 'number' ✅El <T> declara un parámetro de tipo — piénsalo como una variable para
tipos. TypeScript infiere qué es T a partir del argumento que pasas, así que casi
nunca necesitas escribirlo explícitamente. identity<string>('hello') funciona, pero también
identity('hello') — TypeScript lo deduce.
Funciones genéricas en la práctica
La función identity es el ejemplo de enseñanza canónico pero no algo que escribirías en producción. Aquí están los tipos de funciones genéricas que realmente aparecen en bases de código reales:
// A type-safe array first/last helper
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
function last<T>(arr: T[]): T | undefined {
return arr[arr.length - 1];
}
const firstUser = first(users); // UserProfile | undefined
const lastOrder = last(orders); // Order | undefined
const firstNum = first([1, 2, 3]); // number | undefined
// Group array items by a key
function groupBy<T, K extends string | number>(
items: T[],
getKey: (item: T) => K
): Record<K, T[]> {
return items.reduce((acc, item) => {
const key = getKey(item);
if (!acc[key]) acc[key] = [];
acc[key].push(item);
return acc;
}, {} as Record<K, T[]>);
}
// TypeScript infers T as Order and K as string
const byStatus = groupBy(orders, order => order.status);
// byStatus: Record<string, Order[]>
// byStatus['pending'] is Order[] ✅Observa cómo TypeScript infiere T y K automáticamente según cómo
llamas a la función. Obtienes seguridad de tipos completa en el valor de retorno sin escribir una sola
anotación de tipo explícita en el punto de llamada. Ese es el beneficio.
Interfaces y tipos genéricos
Aquí es donde los genéricos se vuelven indispensables para el trabajo diario. Cada base de código que habla con una API termina necesitando un puñado de tipos wrapper genéricos. Aquí están los que verás (y escribirás) constantemente:
// API response wrapper — used across every endpoint
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: string;
}
// Paginated list response
interface PaginatedResult<T> {
items: T[];
page: number;
pageSize: number;
totalItems: number;
totalPages: number;
}
// Using them with concrete types
interface UserProfile {
id: number;
name: string;
email: string;
avatarUrl: string;
}
interface Order {
id: number;
userId: number;
total: number;
status: 'pending' | 'shipped' | 'delivered';
}
// The return types are fully typed — no casting needed downstream
async function getUser(id: number): Promise<ApiResponse<UserProfile>> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
async function listOrders(page: number): Promise<PaginatedResult<Order>> {
const res = await fetch(`/api/orders?page=${page}`);
return res.json();
}
const response = await getUser(1);
response.data.email; // ✅ string
response.data.avatarUrl; // ✅ string
const result = await listOrders(1);
result.items[0].status; // ✅ 'pending' | 'shipped' | 'delivered'
result.totalPages; // ✅ numberUserProfile, Order) automáticamente desde una respuesta de muestra. Luego
los conectas a tus wrappers genéricos.Restricciones genéricas
A veces quieres aceptar múltiples tipos, pero no absolutamente cualquier tipo. Las
restricciones genéricas te permiten especificar qué debe tener un parámetro de tipo. La sintaxis es
T extends SomeType — que significa "T debe ser asignable a SomeType":
// Without constraint — TypeScript doesn't know T has an id property
function findById<T>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id); // ❌ Property 'id' does not exist on type 'T'
}
// With constraint — T must have at least an id: number field
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id); // ✅
}
// Works with any type that has id: number
const user = findById(users, 42); // UserProfile | undefined
const order = findById(orders, 101); // Order | undefined
// Another common constraint — T must be an object (excludes primitives)
function mergeDefaults<T extends object>(partial: Partial<T>, defaults: T): T {
return { ...defaults, ...partial };
}
// T must have a name: string and email: string
function formatContact<T extends { name: string; email: string }>(contact: T): string {
return `${contact.name} <${contact.email}>`;
}
// Works on any object with those two fields — UserProfile, Employee, whatever
formatContact({ name: 'Alice', email: '[email protected]', role: 'admin' }); // ✅La restricción no bloquea T exactamente a { id: number } —
significa que T debe tener al menos esa forma. Así que pasar un
UserProfile completo con diez campos está bien. Esto es el tipado estructural en acción, que es
una de las características más poderosas y
bien documentadas
de TypeScript.
La restricción keyof
Uno de los patrones genéricos más útiles en la biblioteca estándar de TypeScript y bases de código reales
es combinar genéricos con keyof. Te permite escribir funciones que
aceptan un nombre de propiedad de un tipo de objeto y garantizan que el tipo de retorno coincide con esa propiedad.
// keyof T is the union of all keys of T as string literals
// K extends keyof T means K must be one of those keys
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user: UserProfile = {
id: 1,
name: 'Alice',
email: '[email protected]',
avatarUrl: 'https://example.com/avatar.png'
};
const name = getProperty(user, 'name'); // string ✅
const id = getProperty(user, 'id'); // number ✅
const email = getProperty(user, 'email'); // string ✅
getProperty(user, 'password'); // ❌ Argument of type '"password"' is not assignable to
// parameter of type 'keyof UserProfile'
// Real use case: a generic sort function
function sortBy<T>(items: T[], key: keyof T): T[] {
return [...items].sort((a, b) => {
const av = a[key];
const bv = b[key];
return av < bv ? -1 : av > bv ? 1 : 0;
});
}
const byName = sortBy(users, 'name'); // ✅ sorted by name
const byId = sortBy(users, 'id'); // ✅ sorted by id
sortBy(users, 'nonexistent'); // ❌ caught at compile timeParámetros de tipo por defecto
Al igual que los parámetros de función pueden tener valores por defecto, los parámetros de tipo también pueden. Esto es útil cuando tienes un tipo genérico que casi siempre se usa con un tipo específico, pero quieres mantener la flexibilidad para los casos cuando no es así.
// Default type parameter — T defaults to string if not specified
interface Cache<T = string> {
get(key: string): T | undefined;
set(key: string, value: T, ttlMs?: number): void;
delete(key: string): void;
clear(): void;
}
// Without specifying T — uses the default (string)
declare const stringCache: Cache;
const val = stringCache.get('theme'); // string | undefined
// Specifying a different T
declare const userCache: Cache<UserProfile>;
const user2 = userCache.get('user:42'); // UserProfile | undefined
// Another useful example: an event emitter with a typed payload
interface TypedEvent<TPayload = void> {
subscribe(handler: (payload: TPayload) => void): () => void;
emit(payload: TPayload): void;
}
// Events with no payload — default void keeps the API clean
const appReady: TypedEvent = { /* ... */ };
appReady.emit(); // no argument needed
// Events with a payload
const userLoggedIn: TypedEvent<{ userId: number; timestamp: Date }> = { /* ... */ };
userLoggedIn.emit({ userId: 42, timestamp: new Date() });Tipos de utilidad genéricos integrados
TypeScript incluye un conjunto de tipos de utilidad integrados que todos están implementados usando los genéricos que aprendiste arriba. Entender cómo usarlos es una de las habilidades TypeScript más prácticas que puedes tener. Aquí está el conjunto básico con ejemplos reales que muestran el tipo de salida:
interface UserProfile {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'viewer';
passwordHash: string;
createdAt: Date;
}
// Partial<T> — all fields become optional (great for PATCH payloads)
type UpdateUserPayload = Partial<UserProfile>;
// { id?: number; name?: string; email?: string; role?: ...; ... }
// Required<T> — all fields become required (reverse of Partial)
interface DraftConfig {
apiUrl?: string;
timeout?: number;
maxRetries?: number;
}
type ResolvedConfig = Required<DraftConfig>;
// { apiUrl: string; timeout: number; maxRetries: number }
// Pick<T, K> — keep only the named fields
type UserSummary = Pick<UserProfile, 'id' | 'name' | 'email'>;
// { id: number; name: string; email: string }
// Use in list views — only ship what the UI needs
// Omit<T, K> — exclude the named fields
type PublicUserProfile = Omit<UserProfile, 'passwordHash'>;
// { id: number; name: string; email: string; role: ...; createdAt: Date }
// Safe to include in API responses
// Record<K, V> — typed dictionary / map
type PermissionMap = Record<UserProfile['role'], string[]>;
// { admin: string[]; user: string[]; viewer: string[] }
const permissions: PermissionMap = {
admin: ['read', 'write', 'delete', 'admin'],
user: ['read', 'write'],
viewer: ['read']
};
// ReturnType<T> — infer what a function returns (keeps types in sync automatically)
function buildUserSession(user: UserProfile) {
return {
token: crypto.randomUUID(),
userId: user.id,
role: user.role,
expiresAt: new Date(Date.now() + 3_600_000)
};
}
type UserSession = ReturnType<typeof buildUserSession>;
// { token: string; userId: number; role: 'admin' | 'user' | 'viewer'; expiresAt: Date }
// Change buildUserSession and UserSession updates automatically ✅Si estás convirtiendo JavaScript existente a TypeScript y construyendo estos tipos desde cero, la herramienta JS a TypeScript puede ayudarte a poner en marcha los tipos iniciales para que puedas empezar a añadir tipos de utilidad encima.
Un ejemplo realista de extremo a extremo: Cliente API tipado
Aquí está el patrón al que recurro cada vez que construyo una capa de API tipada. Combina funciones genéricas, interfaces genéricas y tipos de utilidad en algo que te da seguridad de tipos de extremo a extremo desde la llamada fetch hasta el componente que consume los datos:
// The response envelope — wraps every API response
interface ApiResponse<T> {
data: T;
meta: {
requestId: string;
duration: number;
};
}
// Error envelope — what the API sends on failure
interface ApiError {
code: string;
message: string;
details?: Record<string, string[]>;
}
// The generic fetch wrapper
async function fetchApi<T>(
url: string,
options?: RequestInit
): Promise<ApiResponse<T>> {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...options?.headers },
...options
});
if (!res.ok) {
const err: ApiError = await res.json();
throw new Error(`[${err.code}] ${err.message}`);
}
return res.json();
}
// Typed endpoint functions — T is set at each call site
async function getUser(id: number): Promise<UserProfile> {
const { data } = await fetchApi<UserProfile>(`/api/users/${id}`);
return data;
}
async function listOrders(userId: number, page = 1): Promise<PaginatedResult<Order>> {
const { data } = await fetchApi<PaginatedResult<Order>>(
`/api/users/${userId}/orders?page=${page}`
);
return data;
}
async function createOrder(
payload: Pick<Order, 'userId' | 'total'>
): Promise<Order> {
const { data } = await fetchApi<Order>('/api/orders', {
method: 'POST',
body: JSON.stringify(payload)
});
return data;
}
// Usage — fully typed, no casting anywhere
const user = await getUser(42);
console.log(user.email); // string ✅
const orders = await listOrders(42);
orders.items[0].status; // 'pending' | 'shipped' | 'delivered' ✅
orders.totalPages; // number ✅
const newOrder = await createOrder({ userId: 42, total: 99.99 });
newOrder.id; // number ✅Errores comunes
Algunos patrones a evitar una vez que te sientes cómodo con los genéricos:
- Usar
anydentro de un genérico. Si escribesfunction wrap<T>(val: T): any, has derrotado el propósito. El punto completo es que el tipo fluye a través — usaranycomo tipo de retorno o dentro de la implementación significa que TypeScript ya no puede rastrearlo. - Sobre-restringir con tipos específicos. Escribir
function process<T extends UserProfile>cuando solo necesitasT extends { id: number }es demasiado restrictivo. Usa la restricción mínima que hace funcionar tu implementación — así la función sigue siendo reutilizable entre diferentes tipos que comparten la misma forma. - Recurrir a genéricos cuando una unión funcionaría. Si una función toma ya sea una
stringo unnumbery la lógica es diferente para cada uno,function f(arg: string | number)con un type guard dentro es más limpio que un genérico. Los genéricos brillan cuando la misma lógica aplica a todas las variantes de tipo. - Demasiados parámetros de tipo. Si te encuentras escribiendo
<T, U, V, W>, da un paso atrás. Eso suele ser señal de que la función está haciendo demasiado, o los tipos podrían expresarse como una sola interfaz. El código fuente del compilador TypeScript es una buena referencia — incluso las utilidades complejas raramente necesitan más de 2–3 parámetros de tipo.
Conclusión
Los genéricos son el punto donde TypeScript deja de ser "JavaScript anotado" y empieza
a ser un sistema de tipos genuinamente poderoso. La idea central es simple: captura el tipo como parámetro
para que fluya a través de tu función o interfaz sin perder información. A partir de ahí, las restricciones
te permiten acotar qué es aceptable, keyof te da acceso a propiedades con seguridad de tipos, y
los tipos de utilidad integrados (Partial, Pick, Omit,
Record) manejan los patrones que de otra manera escribirías a mano. El mejor siguiente paso
es abrir un proyecto real, encontrar una función donde usaste any porque no conocías otra forma,
y reemplazarla con un genérico. El
Manual de TypeScript sobre genéricos
y la
referencia de tipos de utilidad
son las dos páginas que vale la pena marcar como favoritas. Y si construyes interfaces desde payloads de API reales,
JSON a TypeScript puede generar los tipos concretos —
entonces es solo cuestión de envolverlos en tus tipos de utilidad genéricos como
ApiResponse<T> y PaginatedResult<T> para obtener
seguridad de tipos de extremo a extremo.