tutoriales·14 min de lectura

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:

typescript
// 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ón

Con genéricos, resuelves ambos problemas:

typescript
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:

LetraUso común
TType -- tipo genérico principal
USegundo tipo genérico
KKey -- claves de objetos
VValue -- valores
EElement -- elementos de colecciones
RReturn -- tipo de retorno

Puedes usar cualquier nombre, pero estas convenciones hacen que tu código sea reconocible para otros desarrolladores.

Forma explicita vs inferencia

typescript
// 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 solo

múltiples parámetros de tipo

typescript
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

typescript
// 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 completo

Si 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

typescript
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 assignable

Función de agrupación

typescript
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

typescript
// 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    // number

Type alias genérico

typescript
// 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

typescript
// 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

typescript
// 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 nombre

keyof constraint

typescript
// 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

typescript
// 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 | undefined

múltiples constraints

typescript
// 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.

typescript
// 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

typescript
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

typescript
// 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:

typescript
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:

typescript
// La implementación real de Partial en TypeScript
type MiPartial<T> = {
  [K in keyof T]?: T[K]
}

Pick<T, K>

Selecciona propiedades especificas:

typescript
// 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 tipo

Omit<T, K>

Excluye propiedades (lo opuesto a Pick):

typescript
// 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:

typescript
// 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

typescript
// 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 property
Tip: 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

tsx
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

tsx
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

tsx
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:

tsx
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

typescript
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

typescript
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

typescript
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

typescript
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:

typescript
// 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')    // boolean

genéricos en clases

typescript
// 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..."

typescript
// 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

typescript
// 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) // MiClase

Error 3: genéricos en arrow functions de JSX

tsx
// 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 JSX
Error 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

typescript
// 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

typescript
// 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

typescript
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

typescript
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 Usuario

Referencia rápida

ConceptoSintaxisEjemplo
Función genéricafunction fn<T>(arg: T): TprimerElemento<string>(['a'])
Interface genéricainterface I<T> { prop: T }Estado<Usuario>
Constraint<T extends Tipo><T extends { id: number }>
Valor por defecto<T = TipoDefault><T = unknown>
keyofK extends keyof TAcceso seguro a propiedades
PartialPartial<T>Propiedades opcionales
PickPick<T, 'a' | 'b'>Seleccionar propiedades
OmitOmit<T, 'a'>Excluir propiedades
RecordRecord<K, V>Objeto tipado
RequiredRequired<T>Todo obligatorio
ReadonlyReadonly<T>Todo inmutable

Recursos adicionales

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.

#typescript#generics#tipos#react

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.