En esta página

Genéricos avanzados y constraints

14 min lectura TextoCap. 3 — Funciones y genéricos

¿Por qué necesitas genéricos avanzados?

Los genéricos básicos (Array<T>, Promise<T>) son el punto de partida, pero TypeScript ofrece un conjunto de herramientas mucho más poderoso para modelar relaciones entre tipos de forma precisa. Cuando empiezas a construir librerías, servicios de datos o formularios reutilizables, los constraints, el operador keyof y los tipos condicionales se convierten en tus mejores aliados.

En esta lección aprenderás a:

  • Restringir parámetros genéricos con extends.
  • Usar keyof para acceder de forma segura a las propiedades de un tipo.
  • Aprovechar el acceso indexado para extraer sub-tipos.
  • Introducir lógica condicional en el sistema de tipos con tipos condicionales.
  • Capturar tipos anónimos con la palabra clave infer.

Constraints con `extends`

Un genérico sin restricciones acepta absolutamente cualquier tipo, lo que puede ser demasiado permisivo. Los constraints limitan el conjunto de tipos válidos usando la sintaxis T extends Tipo.

// Sin constraint: T puede ser string, number, boolean, objeto...
function identidad<T>(valor: T): T {
  return valor;
}

// Con constraint: T debe tener al menos la propiedad `length`
function logLongitud<T extends { length: number }>(valor: T): void {
  console.log(`Longitud: ${valor.length}`);
}

logLongitud("hola");          // 4
logLongitud([1, 2, 3]);       // 3
logLongitud({ length: 10 });  // 10
// logLongitud(42);            // ❌ number no tiene `length`

El constraint { length: number } no exige que T sea exactamente ese tipo, sino que sea un subtipo estructural de él. Así, string, Array y cualquier objeto con length son válidos.

Constraints múltiples con intersección

Puedes combinar varios requisitos con &:

interface TieneId {
  id: number;
}

interface TieneNombre {
  nombre: string;
}

function mostrarEntidad<T extends TieneId & TieneNombre>(entidad: T): string {
  return `[${entidad.id}] ${entidad.nombre}`;
}

mostrarEntidad({ id: 1, nombre: "Tarea A", completada: false }); // válido

El operador `keyof`

keyof T produce una unión de literales de string con los nombres de todas las propiedades de T. Es la pieza fundamental para crear funciones que acceden a propiedades de forma tipada.

interface Producto {
  nombre: string;
  precio: number;
  enStock: boolean;
}

type ClavesProducto = keyof Producto;
// equivale a: "nombre" | "precio" | "enStock"

Cuando combinas keyof con genéricos, obtienes funciones completamente type-safe:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const producto: Producto = { nombre: "Laptop", precio: 1200, enStock: true };

const nombre  = getProperty(producto, "nombre");   // tipo: string
const precio  = getProperty(producto, "precio");   // tipo: number
const activo  = getProperty(producto, "enStock");  // tipo: boolean

El tipo de retorno T[K] es el acceso indexado: el tipo de la propiedad K dentro de T. TypeScript lo resuelve en tiempo de compilación, por lo que cada llamada devuelve el tipo exacto de la propiedad solicitada.


Acceso indexado (Indexed Access Types)

Los tipos de acceso indexado permiten extraer el tipo de una propiedad sin necesidad de un genérico:

interface Pedido {
  id: number;
  cliente: {
    nombre: string;
    email: string;
  };
  lineas: Array<{
    productoId: number;
    cantidad: number;
    precioUnitario: number;
  }>;
}

type Cliente     = Pedido["cliente"];            // { nombre: string; email: string }
type LineaPedido = Pedido["lineas"][number];     // { productoId: number; cantidad: number; precioUnitario: number }
type EmailCliente = Pedido["cliente"]["email"];  // string

[number] como índice extrae el tipo de los elementos de un array. Es extremadamente útil para derivar tipos de estructuras de datos profundas sin duplicar definiciones.


Genéricos con `keyof` en validación de formularios

Un caso de uso real es validar campos de un formulario de forma genérica:

type Errores<T> = Partial<Record<keyof T, string>>;

interface FormularioRegistro {
  nombre: string;
  email: string;
  password: string;
}

function validarCampo<T extends object>(
  datos: T,
  campo: keyof T,
  validator: (valor: T[keyof T]) => string | null
): Errores<T> {
  const error = validator(datos[campo]);
  if (error) {
    return { [campo]: error } as Errores<T>;
  }
  return {};
}

const datos: FormularioRegistro = {
  nombre: "",
  email: "no-es-email",
  password: "123",
};

const errorNombre = validarCampo(datos, "nombre", (v) =>
  typeof v === "string" && v.length === 0 ? "El nombre es obligatorio" : null
);
// { nombre: "El nombre es obligatorio" }

Tipos condicionales

Los tipos condicionales añaden lógica if-else al sistema de tipos con la sintaxis T extends U ? X : Y:

type EsString<T> = T extends string ? true : false;

type A = EsString<string>;  // true
type B = EsString<number>;  // false
type C = EsString<"hola">; // true — los literales extienden su tipo base

Tipos condicionales distributivos

Cuando T es una unión, el tipo condicional se distribuye automáticamente sobre cada miembro:

type Primitivo = string | number | boolean;

type NoBooleano<T> = T extends boolean ? never : T;

type SoloStringsYNumeros = NoBooleano<Primitivo>;
// string | number

Esto es exactamente cómo están implementados los utility types Exclude y Extract de la librería estándar.


La palabra clave `infer`

infer permite capturar un sub-tipo dentro de un tipo condicional y asignarle un nombre para usarlo en la rama true. Es la herramienta que hace posible utility types como ReturnType y Parameters.

// Implementación interna de ReturnType:
type MiReturnType<T extends (...args: unknown[]) => unknown> =
  T extends (...args: unknown[]) => infer R ? R : never;

function calcularDescuento(precio: number, porcentaje: number): number {
  return precio * (1 - porcentaje / 100);
}

type ResultadoDescuento = MiReturnType<typeof calcularDescuento>; // number

Otro ejemplo: extraer el tipo de los elementos de un Promise:

type Awaited<T> = T extends Promise<infer U> ? U : T;

type ValorAsync = Awaited<Promise<string[]>>; // string[]
type ValorSync  = Awaited<number>;            // number

Y para extraer el tipo del primer parámetro de una función:

type PrimerParametro<T extends (...args: unknown[]) => unknown> =
  T extends (primero: infer P, ...resto: unknown[]) => unknown ? P : never;

function saludar(nombre: string, veces: number): void {
  for (let i = 0; i < veces; i++) console.log(`Hola, ${nombre}`);
}

type NombreTipo = PrimerParametro<typeof saludar>; // string

Ejemplo completo: getter type-safe y validador genérico

// ──────────────────────────────────────────────────────────────
// Getter type-safe
// ──────────────────────────────────────────────────────────────
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

function setProperty<T, K extends keyof T>(obj: T, key: K, value: T[K]): T {
  return { ...obj, [key]: value };
}

// ──────────────────────────────────────────────────────────────
// Validador genérico de campos
// ──────────────────────────────────────────────────────────────
type Regla<V> = (valor: V) => string | null;

type EsquemaValidacion<T> = {
  [K in keyof T]?: Regla<T[K]>;
};

function validarFormulario<T extends object>(
  datos: T,
  esquema: EsquemaValidacion<T>
): Partial<Record<keyof T, string>> {
  const errores: Partial<Record<keyof T, string>> = {};

  for (const campo in esquema) {
    const clave = campo as keyof T;
    const regla = esquema[clave];
    if (regla) {
      const error = regla(datos[clave]);
      if (error !== null) {
        errores[clave] = error;
      }
    }
  }

  return errores;
}

// ──────────────────────────────────────────────────────────────
// Uso
// ──────────────────────────────────────────────────────────────
interface FormularioContacto {
  nombre: string;
  email: string;
  mensaje: string;
}

const datosContacto: FormularioContacto = {
  nombre: "Luis",
  email: "no-es-valido",
  mensaje: "",
};

const erroresContacto = validarFormulario(datosContacto, {
  nombre: (v) => (v.trim().length < 2 ? "Mínimo 2 caracteres" : null),
  email:  (v) => (v.includes("@") ? null : "Email inválido"),
  mensaje: (v) => (v.trim().length === 0 ? "El mensaje no puede estar vacío" : null),
});

console.log(erroresContacto);
// { email: "Email inválido", mensaje: "El mensaje no puede estar vacío" }

Resumen

Herramienta Sintaxis Uso principal
Constraint básico T extends Tipo Limitar tipos aceptados
keyof keyof T Unión de claves de un tipo
Acceso indexado T[K] Tipo de una propiedad concreta
Tipo condicional T extends U ? X : Y Lógica en el sistema de tipos
infer infer R (dentro de condicional) Capturar sub-tipos anónimos

Estas herramientas se combinan entre sí: los utility types de la librería estándar (ReturnType, Parameters, Awaited) están todos implementados usando exactamente estos mecanismos. En la siguiente lección verás cómo usar narrowing y type guards para que TypeScript entienda qué tipo concreto tienes en cada rama de ejecución.

Regla de oro de los constraints
Usa `extends` en tus genéricos para restringir el tipo a lo estrictamente necesario. Un constraint demasiado amplio (`T extends object`) pierde información; uno demasiado estrecho hace el genérico inútil.
infer solo funciona dentro de tipos condicionales
La palabra clave `infer` únicamente puede aparecer en la rama `extends` de un tipo condicional. Intentar usarla fuera de ese contexto producirá un error de compilación.