Tipos genéricos en TypeScript: guía Completa con Ejemplos prácticos
Aprende a usar tipos genéricos en TypeScript paso a paso. Funciones, interfaces, constraints, utility types y patrones avanzados en componentes React con ejemplos reales.
Tipos genéricos en TypeScript: guía Completa
Los tipos genéricos en TypeScript te permiten escribir funciones, interfaces y clases que trabajan con cualquier tipo de dato sin sacrificar la seguridad del tipado estático. Son la herramienta que separa el código TypeScript que simplemente compila del código TypeScript que realmente aprovecha el sistema de tipos.
Si alguna vez escribiste una función y la tipaste con any porque no sabias como hacerla funcionar con distintos tipos, los genéricos son exactamente lo que necesitas.
Que son los genéricos y por qué importan
Un genérico es un parámetro de tipo. Igual que una función recibe parámetros de valores, un genérico recibe parámetros de tipos.
Sin genéricos, tienes dos opciones malas:
// Opción 1: Función específica para cada tipo
function primerElementoString(arr: string[]): string {
return arr[0]
}
function primerElementoNumber(arr: number[]): number {
return arr[0]
}
// Opción 2: Usar any (pierdes el tipado)
function primerElemento(arr: any[]): any {
return arr[0]
}
const resultado = primerElemento(['hola', 'mundo'])
// resultado es any -- TypeScript no sabe qué es string
resultado.toUpperCase() // No hay autocompletado ni verificaciónCon genéricos, resuelves ambos problemas:
function primerElemento<T>(arr: T[]): T {
return arr[0]
}
const texto = primerElemento(['hola', 'mundo'])
// texto es string -- TypeScript lo infiere automáticamente
const número = primerElemento([1, 2, 3])
// número es number
texto.toUpperCase() // Autocompletado completo
número.toFixed(2) // Autocompletado completo<T> es el parámetro de tipo. Cuando llamas a primerElemento(['hola', 'mundo']), TypeScript reemplaza T por string automáticamente.
Sintaxis básica: <T> explicado paso a paso
La convención es usar letras mayusculas para los parámetros de tipo:
| Letra | Uso común |
|---|---|
T | Type -- tipo genérico principal |
U | Segundo tipo genérico |
K | Key -- claves de objetos |
V | Value -- valores |
E | Element -- elementos de colecciones |
R | Return -- tipo de retorno |
Puedes usar cualquier nombre, pero estas convenciones hacen que tu código sea reconocible para otros desarrolladores.
Forma explicita vs inferencia
// Forma explicita: tu defines el tipo
const texto = primerElemento<string>(['hola', 'mundo'])
// Forma con inferencia: TypeScript lo deduce del argumento
const texto = primerElemento(['hola', 'mundo'])
// Ambas producen el mismo resultado
// La inferencia es preferible cuando TypeScript puede resolverlo solomúltiples parámetros de tipo
function crearPar<T, U>(primero: T, segundo: U): [T, U] {
return [primero, segundo]
}
const par = crearPar('edad', 30)
// par es [string, number]
const otroPar = crearPar(true, [1, 2, 3])
// otroPar es [boolean, number[]]cuándo usar genéricos
Usa genéricos cuando tengas una función o tipo que opera sobre la estructura de los datos sin importar su tipo concreto. Si la lógica es la misma para strings, numbers u objetos, es candidata a ser genérica.
Funciones genéricas con ejemplos reales
Vamos con funciones que usarias en un proyecto real.
Wrapper para respuestas de API
// Tipo genérico para respuestas de API
type ApiResponse<T> = {
data: T
status: number
message: string
timestamp: string
}
// Función genérica para hacer fetch tipado
async function fetchApi<T>(url: string): Promise<ApiResponse<T>> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Error HTTP: ${response.status}`)
}
const json = await response.json()
return {
data: json as T,
status: response.status,
message: 'OK',
timestamp: new Date().toISOString(),
}
}
// Uso: TypeScript sabe exactamente que tipo tiene data
interface Usuario {
id: number
nombre: string
email: string
}
interface Producto {
id: number
título: string
precio: number
}
const usuarios = await fetchApi<Usuario[]>('/api/usuarios')
// usuarios.data es Usuario[]
usuarios.data[0].nombre // autocompletado completo
const productos = await fetchApi<Producto[]>('/api/productos')
// productos.data es Producto[]
productos.data[0].precio // autocompletado completoSi trabajas con fetch o async/await, este patron te ahorra repetir la misma lógica de manejo de respuestas en cada llamada.
Función para buscar en arrays por propiedad
function buscarPor<T, K extends keyof T>(
items: T[],
propiedad: K,
valor: T[K]
): T | undefined {
return items.find(item => item[propiedad] === valor)
}
const usuarios: Usuario[] = [
{ id: 1, nombre: 'Ana', email: 'ana@mail.com' },
{ id: 2, nombre: 'Carlos', email: 'carlos@mail.com' },
]
// TypeScript sabe que propiedad debe ser 'id' | 'nombre' | 'email'
const ana = buscarPor(usuarios, 'nombre', 'Ana')
// ana es Usuario | undefined
// Error de compilación: 'edad' no existe en Usuario
const error = buscarPor(usuarios, 'edad', 25)
// ^^^^
// Argument of type '"edad"' is not assignableFunción de agrupación
function agruparPor<T>(
items: T[],
clave: keyof T
): Record<string, T[]> {
return items.reduce((grupos, item) => {
const valor = String(item[clave])
if (!grupos[valor]) {
grupos[valor] = []
}
grupos[valor].push(item)
return grupos
}, {} as Record<string, T[]>)
}
interface Pedido {
id: number
estado: 'pendiente' | 'enviado' | 'entregado'
total: number
}
const pedidos: Pedido[] = [
{ id: 1, estado: 'pendiente', total: 100 },
{ id: 2, estado: 'enviado', total: 250 },
{ id: 3, estado: 'pendiente', total: 75 },
]
const porEstado = agruparPor(pedidos, 'estado')
// {
// pendiente: [{ id: 1, ... }, { id: 3, ... }],
// enviado: [{ id: 2, ... }]
// }Interfaces y tipos genéricos
Los genéricos funcionan igual en interfaces y type aliases.
Interface genérica
// Interface genérica para una colección paginada
interface PaginaResultados<T> {
items: T[]
total: number
página: number
porPagina: number
totalPaginas: number
}
// Uso con diferentes tipos
type PaginaUsuarios = PaginaResultados<Usuario>
type PaginaProductos = PaginaResultados<Producto>
// Función que retorna resultados paginados
async function obtenerPaginado<T>(
url: string,
página: number
): Promise<PaginaResultados<T>> {
const response = await fetch(`${url}?page=${página}`)
return response.json()
}
const paginaUsuarios = await obtenerPaginado<Usuario>('/api/usuarios', 1)
paginaUsuarios.items[0].nombre // string -- tipado correcto
paginaUsuarios.totalPaginas // numberType alias genérico
// Estado de una operación asíncrona
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string }
// Uso
function procesarEstado<T>(state: AsyncState<T>): string {
switch (state.status) {
case 'idle':
return 'Esperando...'
case 'loading':
return 'Cargando...'
case 'success':
// TypeScript sabe que state.data existe aquí
return `Datos: ${JSON.stringify(state.data)}`
case 'error':
// TypeScript sabe que state.error existe aquí
return `Error: ${state.error}`
}
}
const estadoUsuario: AsyncState<Usuario> = {
status: 'success',
data: { id: 1, nombre: 'Ana', email: 'ana@mail.com' },
}Este patron de discriminated union con genéricos es común en aplicaciones React para manejar estados de carga.
Interface genérica con métodos
// Repositorio genérico (patron común en backends)
interface Repository<T> {
obtenerTodos(): Promise<T[]>
obtenerPorId(id: number): Promise<T | null>
crear(datos: Omit<T, 'id'>): Promise<T>
actualizar(id: number, datos: Partial<T>): Promise<T>
eliminar(id: number): Promise<boolean>
}
// Implementación para usuarios
class UsuarioRepository implements Repository<Usuario> {
async obtenerTodos(): Promise<Usuario[]> {
const res = await fetch('/api/usuarios')
return res.json()
}
async obtenerPorId(id: number): Promise<Usuario | null> {
const res = await fetch(`/api/usuarios/${id}`)
if (!res.ok) return null
return res.json()
}
async crear(datos: Omit<Usuario, 'id'>): Promise<Usuario> {
const res = await fetch('/api/usuarios', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos),
})
return res.json()
}
async actualizar(id: number, datos: Partial<Usuario>): Promise<Usuario> {
const res = await fetch(`/api/usuarios/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos),
})
return res.json()
}
async eliminar(id: number): Promise<boolean> {
const res = await fetch(`/api/usuarios/${id}`, { method: 'DELETE' })
return res.ok
}
}Constraints con extends
Los constraints restringen que tipos puede aceptar un genérico. Esto es fundamental cuándo necesitas acceder a propiedades del tipo dentro de la función.
Constraint básico
// Sin constraint: error
function obtenerNombre<T>(obj: T): string {
return obj.nombre // Error: Property 'nombre' does not exist on type 'T'
}
// Con constraint: funciona
function obtenerNombre<T extends { nombre: string }>(obj: T): string {
return obj.nombre // OK: TypeScript sabe que T tiene nombre
}
obtenerNombre({ nombre: 'Ana', edad: 25 }) // OK
obtenerNombre({ nombre: 'Carlos', rol: 'admin' }) // OK
obtenerNombre({ edad: 25 }) // Error: falta nombrekeyof constraint
// Obtener el valor de una propiedad de forma segura
function obtenerPropiedad<T, K extends keyof T>(obj: T, clave: K): T[K] {
return obj[clave]
}
const usuario = { id: 1, nombre: 'Ana', email: 'ana@mail.com' }
const nombre = obtenerPropiedad(usuario, 'nombre')
// nombre es string
const id = obtenerPropiedad(usuario, 'id')
// id es number
obtenerPropiedad(usuario, 'telefono')
// Error: '"telefono"' is not assignable to '"id" | "nombre" | "email"'keyof en la práctica
keyof T produce una union de todas las claves de T como strings literales. Es la base de muchos patrones avanzados y de los utility types nativos de TypeScript.
Constraint con interface
// Cualquier tipo que tenga un id
interface Identificable {
id: number
}
function encontrarPorId<T extends Identificable>(
items: T[],
id: number
): T | undefined {
return items.find(item => item.id === id)
}
// Funciona con cualquier tipo que tenga id
interface Producto {
id: number
título: string
precio: number
}
interface categoría {
id: number
nombre: string
}
const productos: Producto[] = [
{ id: 1, título: 'Laptop', precio: 999 },
{ id: 2, título: 'Mouse', precio: 25 },
]
const categorías: categoría[] = [
{ id: 1, nombre: 'Electronica' },
{ id: 2, nombre: 'Accesorios' },
]
encontrarPorId(productos, 1) // Producto | undefined
encontrarPorId(categorías, 1) // categoría | undefinedmúltiples constraints
// T debe tener id Y nombre
function mostrarResumen<T extends { id: number; nombre: string }>(
item: T
): string {
return `#${item.id}: ${item.nombre}`
}
// T debe extender dos interfaces
interface ConTimestamp {
createdAt: Date
updatedAt: Date
}
function ordenarPorFecha<T extends Identificable & ConTimestamp>(
items: T[]
): T[] {
return [...items].sort(
(a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
)
}genéricos con valores por defecto
Igual que los parámetros de funciones, los genéricos pueden tener valores por defecto.
// Sin valor por defecto: siempre hay que especificarlo
interface Estado<T> {
datos: T
cargando: boolean
}
// Con valor por defecto: si no especificas T, usa unknown
interface Estado<T = unknown> {
datos: T
cargando: boolean
}
// Ambos son válidos:
const estadoEspecifico: Estado<Usuario> = {
datos: { id: 1, nombre: 'Ana', email: 'ana@mail.com' },
cargando: false,
}
const estadoGeneral: Estado = {
datos: { lo: 'que sea' },
cargando: true,
}Ejemplo práctico: componente de tabla
interface TablaConfig<T = Record<string, unknown>> {
columnas: (keyof T)[]
datos: T[]
ordenarPor?: keyof T
dirección?: 'asc' | 'desc'
}
// Con tipo específico
const configUsuarios: TablaConfig<Usuario> = {
columnas: ['id', 'nombre', 'email'],
datos: usuarios,
ordenarPor: 'nombre',
dirección: 'asc',
}
// Error si pones una columna que no existe
const configMala: TablaConfig<Usuario> = {
columnas: ['id', 'telefono'], // Error: 'telefono' no es keyof Usuario
datos: usuarios,
}Valor por defecto con constraint
// T por defecto es string, pero puede ser cualquier cosa que extienda string | number
type Identificador<T extends string | number = string> = {
valor: T
tipo: T extends string ? 'texto' : 'numerico'
}
const idTexto: Identificador = { valor: 'abc-123', tipo: 'texto' }
const idNumero: Identificador<number> = { valor: 42, tipo: 'numerico' }Utility Types que usan genéricos
TypeScript incluye utility types que estan construidos internamente con genéricos. Entender como funcionan te ayuda a crear los tuyos.
Partial<T>
Hace todas las propiedades opcionales:
interface Usuario {
id: number
nombre: string
email: string
rol: 'admin' | 'usuario'
}
// Partial<Usuario> es equivalente a:
// {
// id?: number
// nombre?: string
// email?: string
// rol?: 'admin' | 'usuario'
// }
// Caso de uso: actualización parcial
function actualizarUsuario(
id: number,
cambios: Partial<Usuario>
): Promise<Usuario> {
return fetch(`/api/usuarios/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cambios),
}).then(res => res.json())
}
// Puedes enviar solo los campos que quieras cambiar
actualizarUsuario(1, { nombre: 'Ana Maria' })
actualizarUsuario(1, { rol: 'admin' })
actualizarUsuario(1, { nombre: 'Ana', email: 'ana@nueva.com' })cómo se implementa internamente:
// La implementación real de Partial en TypeScript
type MiPartial<T> = {
[K in keyof T]?: T[K]
}Pick<T, K>
Selecciona propiedades especificas:
// Solo id y nombre del usuario
type UsuarioResumen = Pick<Usuario, 'id' | 'nombre'>
// { id: number; nombre: string }
// Caso de uso: respuesta de lista (sin datos sensibles)
async function listarUsuarios(): Promise<Pick<Usuario, 'id' | 'nombre'>[]> {
const res = await fetch('/api/usuarios?fields=id,nombre')
return res.json()
}
const lista = await listarUsuarios()
lista[0].id // OK
lista[0].nombre // OK
lista[0].email // Error: no existe en el tipoOmit<T, K>
Excluye propiedades (lo opuesto a Pick):
// Todo menos el id (para crear nuevos registros)
type NuevoUsuario = Omit<Usuario, 'id'>
// { nombre: string; email: string; rol: 'admin' | 'usuario' }
function crearUsuario(datos: Omit<Usuario, 'id'>): Promise<Usuario> {
return fetch('/api/usuarios', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos),
}).then(res => res.json())
}
// No necesitas (ni puedes) pasar id
crearUsuario({
nombre: 'Carlos',
email: 'carlos@mail.com',
rol: 'usuario',
})Record<K, V>
Crea un tipo de objeto con claves y valores tipados:
// Mapa de errores por campo
type CamposFormulario = 'nombre' | 'email' | 'password'
type Errores = Record<CamposFormulario, string[]>
const errores: Errores = {
nombre: ['Nombre requerido'],
email: ['Email invalido', 'Email ya registrado'],
password: ['mínimo 8 caracteres'],
}
// Mapa de configuración
type Entorno = 'development' | 'staging' | 'production'
const config: Record<Entorno, { apiUrl: string; debug: boolean }> = {
development: { apiUrl: 'http://localhost:3000', debug: true },
staging: { apiUrl: 'https://staging.api.com', debug: true },
production: { apiUrl: 'https://api.com', debug: false },
}Combinando utility types
// Crear un tipo para editar: todo opcional excepto id
type EditarUsuario = Pick<Usuario, 'id'> & Partial<Omit<Usuario, 'id'>>
// { id: number; nombre?: string; email?: string; rol?: 'admin' | 'usuario' }
// Tipo para formulario de registro: todo requerido menos id
type FormRegistro = Required<Omit<Usuario, 'id'>>
// { nombre: string; email: string; rol: 'admin' | 'usuario' }
// Tipo readonly (inmutable)
type UsuarioReadonly = Readonly<Usuario>
const usuario: UsuarioReadonly = {
id: 1,
nombre: 'Ana',
email: 'ana@mail.com',
rol: 'admin',
}
usuario.nombre = 'Otro' // Error: Cannot assign to 'nombre' because it is a read-only propertyTip: combina utility types
La fuerza real de estos tipos esta en combinarlos. Pick, Omit, Partial y Required cubren la mayoria de las transformaciones de tipos que necesitas en el día a día.
Patrones avanzados: genéricos en componentes React
Los genéricos son especialmente útiles en componentes React que necesitan ser reutilizables con diferentes tipos de datos. Si ya manejas los hooks y ciclo de vida de React, esto te va a resultar natural.
Componente de lista genérica
interface ListaProps<T> {
items: T[]
renderItem: (item: T, index: number) => React.ReactNode
keyExtractor: (item: T) => string | number
emptyMessage?: string
}
function Lista<T>({ items, renderItem, keyExtractor, emptyMessage }: ListaProps<T>) {
if (items.length === 0) {
return <p>{emptyMessage ?? 'No hay elementos'}</p>
}
return (
<ul>
{items.map((item, index) => (
<li key={keyExtractor(item)}>
{renderItem(item, index)}
</li>
))}
</ul>
)
}
// Uso con usuarios
<Lista<Usuario>
items={usuarios}
keyExtractor={(u) => u.id}
renderItem={(usuario) => (
<div>
<strong>{usuario.nombre}</strong>
<span>{usuario.email}</span>
</div>
)}
/>
// Uso con productos -- mismo componente, diferente tipo
<Lista<Producto>
items={productos}
keyExtractor={(p) => p.id}
renderItem={(producto) => (
<div>
<strong>{producto.título}</strong>
<span>${producto.precio}</span>
</div>
)}
/>Hook genérico para fetch de datos
import { useState, useEffect } from 'react'
interface UseFetchResult<T> {
data: T | null
error: string | null
loading: boolean
refetch: () => void
}
function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const fetchData = async () => {
setLoading(true)
setError(null)
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Error: ${response.status}`)
}
const json: T = await response.json()
setData(json)
} catch (err) {
setError(err instanceof Error ? err.message : 'Error desconocido')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchData()
}, [url])
return { data, error, loading, refetch: fetchData }
}
// Uso en un componente
function PaginaUsuarios() {
const { data, loading, error } = useFetch<Usuario[]>('/api/usuarios')
if (loading) return <p>Cargando...</p>
if (error) return <p>Error: {error}</p>
if (!data) return null
return (
<ul>
{data.map(usuario => (
<li key={usuario.id}>{usuario.nombre}</li>
))}
</ul>
)
}Select genérico con tipado completo
interface SelectProps<T> {
options: T[]
value: T | null
onChange: (selected: T) => void
getLabel: (option: T) => string
getValue: (option: T) => string | number
placeholder?: string
}
function Select<T>({
options,
value,
onChange,
getLabel,
getValue,
placeholder = 'Selecciona una opción',
}: SelectProps<T>) {
return (
<select
value={value ? String(getValue(value)) : ''}
onChange={(e) => {
const selected = options.find(
(opt) => String(getValue(opt)) === e.target.value
)
if (selected) onChange(selected)
}}
>
<option value="" disabled>
{placeholder}
</option>
{options.map((option) => (
<option key={getValue(option)} value={getValue(option)}>
{getLabel(option)}
</option>
))}
</select>
)
}
// Uso
interface Pais {
código: string
nombre: string
población: number
}
const paises: Pais[] = [
{ código: 'MX', nombre: 'Mexico', población: 128900000 },
{ código: 'CO', nombre: 'Colombia', población: 51870000 },
{ código: 'AR', nombre: 'Argentina', población: 45380000 },
]
<Select<Pais>
options={paises}
value={paisSeleccionado}
onChange={(pais) => {
// pais es Pais, no any
console.log(pais.código, pais.población)
}}
getLabel={(p) => p.nombre}
getValue={(p) => p.código}
/>genéricos en formularios con Zod
Si usas Zod para validar schemas, puedes combinar z.infer con genéricos para tener formularios completamente tipados:
import { z } from 'zod'
// Schema reutilizable con genéricos
function crearFormulario<T extends z.ZodObject<any>>(schema: T) {
type FormData = z.infer<T>
return {
validar: (datos: unknown): FormData => schema.parse(datos),
validarSeguro: (datos: unknown) => schema.safeParse(datos),
valoresIniciales: () => {
const shape = schema.shape
const inicial: Record<string, unknown> = {}
for (const key of Object.keys(shape)) {
inicial[key] = ''
}
return inicial as FormData
},
}
}
// Uso
const registroSchema = z.object({
nombre: z.string().min(2),
email: z.string().email(),
edad: z.number().min(18),
})
const formulario = crearFormulario(registroSchema)
// TypeScript sabe el tipo exacto
const datos = formulario.validar({
nombre: 'Ana',
email: 'ana@mail.com',
edad: 25,
})
// datos es { nombre: string; email: string; edad: number }Crear tus propios utility types
Una vez que entiendes los genéricos, puedes crear utility types personalizados para tu proyecto.
NonNullableProps: eliminar null de propiedades
type NonNullableProps<T> = {
[K in keyof T]: NonNullable<T[K]>
}
interface FormularioContacto {
nombre: string | null
email: string | null
mensaje: string | null
}
type FormularioCompleto = NonNullableProps<FormularioContacto>
// { nombre: string; email: string; mensaje: string }PickByType: seleccionar propiedades por tipo
type PickByType<T, ValueType> = {
[K in keyof T as T[K] extends ValueType ? K : never]: T[K]
}
interface Producto {
id: number
título: string
descripción: string
precio: number
enStock: boolean
}
type CamposTexto = PickByType<Producto, string>
// { título: string; descripción: string }
type CamposNumericos = PickByType<Producto, number>
// { id: number; precio: number }DeepPartial: Partial recursivo
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}
interface Configuración {
base: {
apiUrl: string
timeout: number
}
auth: {
token: string
refreshToken: string
opciones: {
recordar: boolean
expiración: number
}
}
}
// Con Partial normal: solo el primer nivel es opcional
type ConfigParcial = Partial<Configuración>
// base y auth son opcionales, pero sus propiedades internas siguen requeridas
// Con DeepPartial: todo es opcional en todos los niveles
type ConfigDeepParcial = DeepPartial<Configuración>
const config: ConfigDeepParcial = {
auth: {
opciones: {
recordar: true,
// expiración es opcional
},
// token y refreshToken son opcionales
},
// base es opcional
}PathsOf: obtener todas las rutas de un objeto
type PathsOf<T, Prefix extends string = ''> = {
[K in keyof T & string]: T[K] extends object
? PathsOf<T[K], `${Prefix}${K}.`>
: `${Prefix}${K}`
}[keyof T & string]
// útil para acceder a valores anidados de forma segura
type ConfigPaths = PathsOf<Configuración>
// "base.apiUrl" | "base.timeout" | "auth.token" | "auth.refreshToken" | ...Cuidado con la complejidad
Los utility types recursivos son poderosos pero pueden ralentizar el compilador de TypeScript en proyectos grandes. Usa DeepPartial y tipos recursivos solo cuando realmente los necesites.
genéricos con funciones de tipo overload
A veces necesitas que una función retorne tipos diferentes según los argumentos:
// Overloads + genéricos
function parsear<T extends 'string' | 'number' | 'boolean'>(
valor: string,
tipo: T
): T extends 'string'
? string
: T extends 'number'
? number
: boolean {
switch (tipo) {
case 'string':
return valor as any
case 'number':
return Number(valor) as any
case 'boolean':
return (valor === 'true') as any
default:
throw new Error(`Tipo no soportado: ${tipo}`)
}
}
const texto = parsear('hola', 'string') // string
const número = parsear('42', 'number') // number
const bool = parsear('true', 'boolean') // booleangenéricos en clases
// Cache genérico con TTL (time to live)
class Cache<T> {
private store = new Map<string, { value: T; expiresAt: number }>()
constructor(private ttlMs: number = 60000) {}
set(key: string, value: T): void {
this.store.set(key, {
value,
expiresAt: Date.now() + this.ttlMs,
})
}
get(key: string): T | null {
const entry = this.store.get(key)
if (!entry) return null
if (Date.now() > entry.expiresAt) {
this.store.delete(key)
return null
}
return entry.value
}
clear(): void {
this.store.clear()
}
}
// Cache de usuarios (5 minutos)
const cacheUsuarios = new Cache<Usuario>(5 * 60 * 1000)
cacheUsuarios.set('user-1', { id: 1, nombre: 'Ana', email: 'ana@mail.com' })
const usuario = cacheUsuarios.get('user-1')
// usuario es Usuario | null
// Cache de productos (1 minuto)
const cacheProductos = new Cache<Producto>(60000)Errores comunes y como resolverlos
Error 1: "Type 'T' is not assignable to..."
// MAL: T puede ser cualquier cosa
function duplicar<T>(valor: T): T {
return valor * 2
// ^^^^^^^^^ Error: T no se puede multiplicar
}
// BIEN: restringir T a number
function duplicar<T extends number>(valor: T): number {
return valor * 2
}Error 2: No puedes crear instancias de un tipo genérico
// MAL: no puedes hacer new T()
function crearInstancia<T>(): T {
return new T()
// ^^^^^ Error: 'T' only refers to a type
}
// BIEN: pasar un constructor como argumento
function crearInstancia<T>(Constructor: new () => T): T {
return new Constructor()
}
class MiClase {
nombre = 'instancia'
}
const obj = crearInstancia(MiClase) // MiClaseError 3: genéricos en arrow functions de JSX
// MAL: el <T> se confunde con JSX
const identidad = <T>(valor: T): T => valor
// ^ Error: JSX element 'T' has no corresponding closing tag
// BIEN: agregar extends para desambiguar
const identidad = <T extends unknown>(valor: T): T => valor
// BIEN: usar una interface constraint
const identidad = <T,>(valor: T): T => valor
// La coma después de T le dice a TypeScript qué es un genérico, no JSXError frecuente en React
En archivos .tsx, <T> se interpreta como JSX. Usa <T,> o <T extends unknown> para que TypeScript lo trate como un parámetro de tipo genérico.
Error 4: Inferencia incorrecta con objetos literales
// MAL: TypeScript infiere un tipo demasiado amplio
function crearConfig<T>(config: T): T {
return config
}
const config = crearConfig({
url: 'https://api.com',
timeout: 5000,
})
// config es { url: string; timeout: number }
// Pierde el literal 'https://api.com'
// BIEN: usar const assertion
const config = crearConfig({
url: 'https://api.com',
timeout: 5000,
} as const)
// config es { readonly url: 'https://api.com'; readonly timeout: 5000 }Error 5: Demasiados genéricos
// MAL: difícil de leer y usar
function procesar<T, U, V, W, X>(
datos: T,
transformar: (d: T) => U,
filtrar: (d: U) => V,
mapear: (d: V) => W,
reducir: (d: W) => X
): X {
return reducir(mapear(filtrar(transformar(datos))))
}
// BIEN: dividir en funciones más pequeñas
function transformar<T, U>(datos: T, fn: (d: T) => U): U {
return fn(datos)
}Regla práctica
Si tu función tiene más de 3 parámetros de tipo genérico, probablemente necesita ser dividida en funciones más pequeñas. El código genérico debe simplificar, no complicar.
Patrones útiles del mundo real
Event emitter tipado
type EventMap = Record<string, unknown>
class TypedEmitter<Events extends EventMap> {
private listeners = new Map<keyof Events, Set<(data: any) => void>>()
on<K extends keyof Events>(
evento: K,
callback: (data: Events[K]) => void
): void {
if (!this.listeners.has(evento)) {
this.listeners.set(evento, new Set())
}
this.listeners.get(evento)!.add(callback)
}
emit<K extends keyof Events>(evento: K, data: Events[K]): void {
this.listeners.get(evento)?.forEach(cb => cb(data))
}
off<K extends keyof Events>(
evento: K,
callback: (data: Events[K]) => void
): void {
this.listeners.get(evento)?.delete(callback)
}
}
// Definir los eventos de tu app
interface AppEvents {
'usuario:login': { id: number; nombre: string }
'usuario:logout': { id: number }
'producto:comprado': { productoId: number; cantidad: number }
'error': { mensaje: string; código: number }
}
const emitter = new TypedEmitter<AppEvents>()
// Autocompletado de eventos y datos
emitter.on('usuario:login', (data) => {
console.log(data.nombre) // TypeScript sabe que existe
})
emitter.emit('producto:comprado', {
productoId: 1,
cantidad: 3,
})
// Error: falta 'cantidad'
emitter.emit('producto:comprado', { productoId: 1 })Builder pattern tipado
class QueryBuilder<T> {
private conditions: string[] = []
private selectedFields: (keyof T)[] = []
private orderField?: keyof T
private limitValue?: number
select(...campos: (keyof T)[]): this {
this.selectedFields = campos
return this
}
where(campo: keyof T, operador: '=' | '>' | '<' | '!=', valor: T[keyof T]): this {
this.conditions.push(`${String(campo)} ${operador} '${valor}'`)
return this
}
orderBy(campo: keyof T): this {
this.orderField = campo
return this
}
limit(n: number): this {
this.limitValue = n
return this
}
build(): string {
const campos = this.selectedFields.length > 0
? this.selectedFields.join(', ')
: '*'
let query = `SELECT ${campos} FROM tabla`
if (this.conditions.length > 0) {
query += ` WHERE ${this.conditions.join(' AND ')}`
}
if (this.orderField) {
query += ` ORDER BY ${String(this.orderField)}`
}
if (this.limitValue) {
query += ` LIMIT ${this.limitValue}`
}
return query
}
}
// Uso tipado
const query = new QueryBuilder<Usuario>()
.select('nombre', 'email')
.where('rol', '=', 'admin')
.orderBy('nombre')
.limit(10)
.build()
// Error en compilación:
new QueryBuilder<Usuario>()
.select('telefono') // Error: 'telefono' no existe en Usuario
.where('edad', '>', 18) // Error: 'edad' no existe en UsuarioReferencia rápida
| Concepto | Sintaxis | Ejemplo |
|---|---|---|
| Función genérica | function fn<T>(arg: T): T | primerElemento<string>(['a']) |
| Interface genérica | interface I<T> { prop: T } | Estado<Usuario> |
| Constraint | <T extends Tipo> | <T extends { id: number }> |
| Valor por defecto | <T = TipoDefault> | <T = unknown> |
| keyof | K extends keyof T | Acceso seguro a propiedades |
| Partial | Partial<T> | Propiedades opcionales |
| Pick | Pick<T, 'a' | 'b'> | Seleccionar propiedades |
| Omit | Omit<T, 'a'> | Excluir propiedades |
| Record | Record<K, V> | Objeto tipado |
| Required | Required<T> | Todo obligatorio |
| Readonly | Readonly<T> | Todo inmutable |
Recursos adicionales
- TypeScript Handbook: Generics -- la documentación oficial es la referencia más completa.
- TypeScript Playground -- experimenta con genéricos directamente en el navegador sin instalar nada.
Preguntas frecuentes
¿Cuándo debo usar genéricos y cuando tipos concretos?
Usa genéricos cuando la lógica de tu función o tipo no depende del tipo concreto. Si una función hace lo mismo con strings, numbers u objetos, es candidata. Si la lógica es específica para un tipo (calcular un descuento, formatear una fecha), usa tipos concretos.
¿Los genéricos afectan el rendimiento en runtime?
No. Los genéricos son un concepto exclusivo de TypeScript que desaparece completamente cuando el código se compila a JavaScript. No hay ningún overhead en runtime -- son solo instrucciones para el compilador.
¿Puedo usar genéricos con Zod?
Si. Puedes usar z.infer<typeof schema> para extraer el tipo de un schema de Zod. Esto combina la validación en runtime de Zod con el tipado estático de TypeScript. Es uno de los patrones más potentes de TypeScript moderno.
¿Cuál es la diferencia entre <T> y <T extends unknown>?
En la práctica, son equivalentes. <T extends unknown> se usa en archivos .tsx para que TypeScript no confunda el genérico con una etiqueta JSX. también puedes usar <T,> (con coma) como alternativa más corta.
¿Cuántos parámetros de tipo genérico es razonable tener?
Como regla general, no más de 3. Si necesitas más, tu función probablemente esta haciendo demasiado y debería dividirse. Las funciones con muchos genéricos son difíciles de leer, difíciles de usar, y difíciles de mantener.
Preguntas frecuentes
¿Qué son los tipos genéricos en TypeScript?
Los tipos genéricos en TypeScript son una forma de crear funciones, interfaces y clases que trabajan con múltiples tipos sin perder el tipado estático. En lugar de usar any, defines un parámetro de tipo como <T> que se resuelve al tipo concreto cuando usas la función o interfaz. Esto te da reutilización y seguridad de tipos al mismo tiempo.
¿Cuál es la diferencia entre usar genéricos y usar any en TypeScript?
Con any pierdes toda la información de tipo y el compilador no puede ayudarte a detectar errores. Con genéricos, TypeScript infiere y mantiene el tipo concreto a lo largo de toda la operación. Si pasas un string a una función genérica, TypeScript sabe que el resultado también es string, algo imposible con any.
¿Cómo usar genéricos en componentes de React con TypeScript?
Defines el componente con un parámetro de tipo genérico, por ejemplo function Lista<T>(props: ListaProps<T>). Esto permite que el componente acepte datos de cualquier tipo manteniendo el tipado correcto en las props, callbacks y renders. Es el patron que usan librerías como React Hook Form y TanStack Table.
¿Qué son los constraints con extends en genéricos de TypeScript?
Los constraints limitan que tipos puede aceptar un genérico. Por ejemplo, <T extends { id: number }> significa que T debe ser un objeto que tenga al menos una propiedad id de tipo number. Esto te permite acceder a propiedades de T dentro de la función con seguridad de tipos.
¿Cuáles son los utility types más usados en TypeScript?
Los más usados son Partial<T> qué hace todas las propiedades opcionales, Pick<T, K> que selecciona propiedades especificas, Omit<T, K> que excluye propiedades, Record<K, V> que crea un tipo de objeto con claves y valores tipados, y Required<T> qué hace todas las propiedades obligatorias. Todos estan construidos con genéricos internamente.
Articulos relacionados
Zod Avanzado: Discriminated Unions, Transforms y Pipes
Patrones avanzados de Zod: discriminated unions, transforms, pipes, preprocess, y como validar datos complejos en TypeScript con schemas reutilizables.
tRPC + Next.js: APIs Type-Safe sin REST
Implementa tRPC en Next.js para APIs 100% type-safe. Sin schemas de API, sin fetch manual, sin types duplicados. End-to-end type safety con TypeScript.
Webhooks en Next.js: Recibe y Procesa Eventos
Implementa webhooks en Next.js para recibir eventos de Stripe, GitHub, Clerk y otros servicios. Verificación de firmas, tipado y manejo de errores.