TypeScript Generics a través de ejemplos reales
1. Descripción general
Generics es una herramienta que te permite escribir código reutilizable manteniendo la seguridad de tipos. En lugar de escribir UserRepository, OrderRepository, ProductRepository una y otra vez, escribes un Repository<T> compartido. Este artículo recorre ejemplos prácticos: funciones genéricas, clases genéricas, constraints, múltiples parámetros de tipo, y cómo combinarlos con utility types. Después de leerlo, estarás seguro aplicando generics en las capas de servicio, repositorio y respuestas de API en tu proyecto TypeScript.
2. Qué son los generics y por qué los necesitas
Generics te permite “parametrizar” tipos, similar a cómo las funciones reciben parámetros pero a nivel de tipo.
// Sin generics: hay que escribir de nuevo para cada tipo
function firstString(arr: string[]): string {
return arr[0];
}
function firstNumber(arr: number[]): number {
return arr[0];
}
// Con generics: una función para cada tipo
function first<T>(arr: T[]): T {
return arr[0];
}
const name = first(["Alice", "Bob"]); // T = string, inferido automáticamente
const age = first([30, 25]); // T = number
<T> es un parámetro de tipo — una variable que representa el tipo real que se pasará al invocar. TypeScript automáticamente infiere T basándose en los argumentos, no necesitas especificarlo manualmente.
Nota:
T,U,K,Vson solo convenciones de nomenclatura, sin significado especial. En bases de código grandes, nombres más descriptivos comoTItem,TResponseserán mucho más legibles.
3. Funciones genéricas en la práctica: la función groupBy
Un ejemplo común al procesar datos: agrupar objetos por un campo.
interface Order {
id: number;
status: "pending" | "paid" | "cancelled";
customerId: number;
}
function groupBy<T, K extends string | number>(
items: T[],
keyFn: (item: T) => K
): Record<K, T[]> {
const result = {} as Record<K, T[]>;
for (const item of items) {
const key = keyFn(item);
(result[key] ??= []).push(item);
}
return result;
}
const orders: Order[] = [
{ id: 1, status: "paid", customerId: 10 },
{ id: 2, status: "pending", customerId: 11 },
{ id: 3, status: "paid", customerId: 10 },
];
const byStatus = groupBy(orders, (o) => o.status);
Tes el tipo del elemento en el array (Order).K extends string | numberlimita la clave a solostringonumber— porque las claves de objeto no pueden ser de otros tipos.- El resultado
byStatustiene tipoRecord<"pending" | "paid" | "cancelled", Order[]>, TypeScript sabe exactamente qué claves pueden aparecer.
byStatus.paid -> Order[] (2 elementos)
byStatus.pending -> Order[] (1 elemento)
byStatus.cancelled -> Order[] (puede ser undefined si strict)
4. Constraints genéricos con extends
Un constraint limita T a satisfacer cierta forma, permitiéndote acceder a propiedades con seguridad dentro de una función genérica.
interface HasId {
id: number;
}
function findById<T extends HasId>(items: T[], id: number): T | undefined {
return items.find((item) => item.id === id);
}
interface Customer extends HasId {
name: string;
email: string;
}
const customers: Customer[] = [
{ id: 1, name: "Ana", email: "ana@example.com" },
{ id: 2, name: "Beto", email: "beto@example.com" },
];
const found = findById(customers, 1); // Customer | undefined
Sin extends HasId, la línea item.id causaría un error porque TypeScript no sabe si T tiene la propiedad id.
error TS2339: Property 'id' does not exist on type 'T'.
Este es el error más común al aprender generics: olvidar el constraint e intentar acceder a propiedades arbitrarias en T.
5. Clases genéricas: ejemplo Repository<T>
Este es el caso de uso clásico — un repositorio genérico para múltiples entidades.
interface Entity {
id: number;
}
class Repository<T extends Entity> {
private items: Map<number, T> = new Map();
save(item: T): T {
this.items.set(item.id, item);
return item;
}
findById(id: number): T | undefined {
return this.items.get(id);
}
findAll(): T[] {
return Array.from(this.items.values());
}
deleteById(id: number): boolean {
return this.items.delete(id);
}
}
interface Product extends Entity {
name: string;
price: number;
}
const productRepo = new Repository<Product>();
productRepo.save({ id: 1, name: "Teclado", price: 500_000 });
productRepo.save({ id: 2, name: "Ratón", price: 200_000 });
const all = productRepo.findAll(); // Product[]
Repository<T extends Entity>asegura que toda entidad gestionada tieneid, entoncesMap<number, T>puede usaridcomo clave de forma segura.- Cuando declaras
new Repository<Product>(), todos los métodos retornan exactamente el tipoProduct— el IDE autocompletaname,priceinmediatamente.
Nota: Si solo usas
Repositorypara una única entidad en todo tu proyecto, los generics son excesivos. Escribe una clase específica primero, solo genericiza cuando tengas ≥ 2 lugares reutilizando la lógica.
6. Múltiples parámetros de tipo: ejemplo ApiResponse<TData, TError>
Cuando necesitas parametrizar más de un tipo, usas múltiples parámetros de tipo simultáneamente.
interface ApiResponse<TData, TError = string> {
success: boolean;
data?: TData;
error?: TError;
}
function fetchSuccess<T>(data: T): ApiResponse<T> {
return { success: true, data };
}
function fetchFailure<T, E = string>(error: E): ApiResponse<T, E> {
return { success: false, error };
}
interface User {
id: number;
username: string;
}
const okResponse: ApiResponse<User> = fetchSuccess({ id: 1, username: "nonladev" });
const errResponse: ApiResponse<User, { code: number; message: string }> = fetchFailure({
code: 404,
message: "Usuario no encontrado",
});
TError = string en la declaración de la interfaz es un tipo genérico por defecto — si no pasas un segundo tipo, TypeScript usa automáticamente string. Por eso ApiResponse<User> es válido aunque solo tengas 1 argumento de tipo.
7. Combinar generics con utility types
Los utility types como Partial, Pick, Omit son en realidad tipos genéricos predefinidos en TypeScript. Combinarlos con tus propios generics es muy efectivo para funciones de actualización.
interface Customer {
id: number;
name: string;
email: string;
vip: boolean;
}
function updateEntity<T extends { id: number }>(
original: T,
patch: Partial<Omit<T, "id">>
): T {
return { ...original, ...patch };
}
const customer: Customer = { id: 1, name: "Ana", email: "ana@a.com", vip: false };
const updated = updateEntity(customer, { vip: true });
// updated: Customer { id: 1, name: "Ana", email: "ana@a.com", vip: true }
Partial<Omit<T, "id">>significa: toma todos los campos deTexceptoid, luego hace que todos esos campos sean opcionales.- De esta forma,
updateEntityno permite cambiarid, y el llamador solo necesita pasar los campos que quiere cambiar — muy similar al patrón PATCH en REST API.
Si intentas pasar { id: 2 } en patch, TypeScript mostrará un error inmediatamente porque id fue excluido por Omit del tipo permitido:
error TS2345: Object literal may only specify known properties,
and 'id' does not exist in type 'Partial<Omit<Customer, "id">>'.
8. Generics con conditional types: cuándo necesitas infer
Un paso más avanzado: extraer automáticamente el tipo de retorno de una función, evitando duplicación manual.
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
async function fetchCustomer(id: number): Promise<Customer> {
return { id, name: "Ana", email: "ana@a.com", vip: false };
}
type FetchResult = UnwrapPromise<ReturnType<typeof fetchCustomer>>;
// FetchResult = Customer, no Promise<Customer>
infer Ule dice a TypeScript que “adivine” el tipo dentro dePromise<...>y lo asigne aU.- No necesitas saber de antemano qué devuelve
fetchCustomer— el tipo se infiere automáticamente, si la firma defetchCustomercambia,FetchResultse actualiza automáticamente.
Nota: Los conditional types con
inferson muy potentes pero fáciles de hacer ilegible. Solo úsalos cuando necesites una utilidad de tipo reutilizable (comoUnwrapPromise), no los uses excesivamente para lógica de un solo uso.
9. Escribir tests para funciones genéricas
Los generics afectan el type-checking en tiempo de compilación, pero el comportamiento en tiempo de ejecución aún necesita ser testeado normalmente. Instala Vitest:
<!-- package.json (sección dependencies, no es pom.xml) -->
npm install -D vitest typescript
// repository.test.ts
import { describe, it, expect } from "vitest";
interface Entity {
id: number;
}
class Repository<T extends Entity> {
private items: Map<number, T> = new Map();
save(item: T): T {
this.items.set(item.id, item);
return item;
}
findById(id: number): T | undefined {
return this.items.get(id);
}
}
interface Product extends Entity {
name: string;
}
describe("Repository<Product>", () => {
it("guarda y recupera el producto correcto por id", () => {
const repo = new Repository<Product>();
repo.save({ id: 1, name: "Teclado" });
const found = repo.findById(1);
expect(found).toBeDefined();
expect(found?.name).toBe("Teclado");
});
it("devuelve undefined cuando el id no existe", () => {
const repo = new Repository<Product>();
const found = repo.findById(999);
expect(found).toBeUndefined();
});
});
Ejecuta npx vitest run, resultado esperado:
✓ Repository<Product> > guarda y recupera el producto correcto por id
✓ Repository<Product> > devuelve undefined cuando el id no existe
Test Files 1 passed (1)
Tests 2 passed (2)
Este test no valida tipos (el compilador de TypeScript ya lo hizo durante la compilación), sino que verifica la lógica en tiempo de ejecución de la clase genérica — asegurando que Map funciona correctamente sin importar qué sea T.
10. Errores comunes y cuándo no deberías usar generics
- Generics excesivos (over-generic): crear
Repository<T>cuando tu proyecto solo tiene una entidad. En este caso, una clase específicaCustomerRepositoryes más legible y mantenible. - Olvidar constraints: escribir
function getProp<T>(obj: T, key: string)y luego acceder aobj[key]causará un error tipoanyo error de firma de índice. Deberías usar<T, K extends keyof T>(obj: T, key: K): T[K]. - Usar
anyen lugar de generics:function identity(x: any): anydestroye completamente la seguridad de tipos — TypeScript no reportará ningún error. Siempre prefiere<T>(x: T): T. - Demasiados parámetros de tipo: si una función tiene 4-5 parámetros de tipo, el código es muy difícil de leer. Considera agrupar los tipos en una única interfaz de configuración.
// Difícil de leer
function merge<A, B, C, D>(a: A, b: B, c: C, d: D) { /* ... */ }
// Más legible
interface MergeInput<TBase, TExtra> {
base: TBase;
extra: TExtra;
}
function merge<TBase, TExtra>(input: MergeInput<TBase, TExtra>) { /* ... */ }
Nota: Si un parámetro de tipo se usa solo una vez en toda la firma de la función, probablemente no necesites un genérico — un tipo concreto o
unknownes suficiente.
11. Conclusión
Los generics te permiten escribir código reutilizable sin sacrificar la seguridad de tipos: un Repository<T>, un ApiResponse<TData, TError> en lugar de docenas de clases/interfaces duplicadas. La clave es dominar tres cosas — parámetros de tipo básicos, constraints con extends, y cómo combinarlos con utility types disponibles (Partial, Omit, Pick). No genericizes todo desde el principio; solo extrae genéricos cuando veas lógica repetida en al menos dos lugares diferentes en tu base de código.