Tipos avanzados en TypeScript

Lección 7 de 8. Los tipos avanzados en TypeScript son casi un lenguaje dentro del lenguaje: tienen bucles (tipos mapeados), condiciones (tipos condicionales) y hasta plantillas de texto. Aquí verás cómo están hechos los utilitarios de la lección anterior y dos herramientas que sí usarás a diario: as const y satisfies.

Antes de empezar, una advertencia honesta: esta lección es la que menos vas a escribir en el día a día. Su utilidad principal es leer los tipos de las bibliotecas sin asustarte y, de vez en cuando, ahorrarte cien líneas de tipos repetidos. Si algo no encaja a la primera, sigue: la lección 8 no depende de esta.

Tipos mapeados: un bucle sobre las claves

¿Cómo está hecho Partial, de la lección 6? Es un tipo mapeado: recorre las claves de un tipo con [K in keyof T] y, para cada una, escribe una propiedad nueva. Se pueden escribir en una línea:

type Opcional<T> = { [K in keyof T]?: T[K] };           // lo mismo que Partial<T>
type SoloLectura<T> = { readonly [K in keyof T]: T[K] }; // lo mismo que Readonly<T>
type Obligatorio<T> = { [K in keyof T]-?: T[K] };       // -? quita el opcional: Required<T>

interface Producto { nombre: string; precio: number; stock?: number }

const borrador: Opcional<Producto> = { nombre: "Taza" };
const fijo: SoloLectura<Producto> = { nombre: "Vaso", precio: 3 };
const completo: Obligatorio<Producto> = { nombre: "Jarra", precio: 9, stock: 4 };

fijo.precio = 4;                                         // error: readonly
const incompleto: Obligatorio<Producto> = { nombre: "Plato", precio: 5 };  // error: falta stock

Ábrelo en el editor y cámbialo →

tsc --strictmapeado.ts(11,6): error TS2540: No se puede asignar a "precio" porque es una propiedad de solo lectura.
mapeado.ts(12,7): error TS2741: La propiedad "stock" falta en el tipo "{ nombre: string; precio: number; }", pero es obligatoria en el tipo "Obligatorio<Producto>".

El tipo mapeado SoloLectura recorre las claves nombre y precio de Producto y crea, para cada una, una propiedad readonly con el mismo tipoUn tipo mapeado recorre las claves y fabrica una propiedad por cada unaProductonombre: stringprecio: numberSoloLectura<Producto>[K in keyof T]:readonly T[K]K = "nombre"T["nombre"] = stringK = "precio"T["precio"] = numberresultadoreadonly nombre: stringreadonly precio: numberEs un bucle, pero sobre tipos: se ejecuta al compilar y no queda nada de él al ejecutar.

El tipo mapeado se evalúa al compilar: para cada clave K de Producto escribe una propiedad con el tipo T[K]. Esquema: decodigo.com.

Con as se puede además renombrar cada clave. Combinado con los tipos de plantilla que verás más abajo, permite fabricar, por ejemplo, un getter por cada propiedad:

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

interface Persona { nombre: string; edad: number }

const p: Getters<Persona> = {
  getNombre: () => "Lucía",
  getEdad: () => 34,
};
console.log(p.getNombre(), p.getEdad() + 1);

Ábrelo en el editor y cámbialo →

salidaLucía 35

Tipos condicionales: un if para tipos

Un tipo condicional elige entre dos tipos según si uno encaja en otro: A extends B ? X : Y se lee «si A cabe en B, X; si no, Y». Cuando A es una unión, se aplica a cada miembro por separado, y eso lo hace muy útil para filtrar uniones:

type SinNulos<T> = T extends null | undefined ? never : T;   // lo mismo que NonNullable<T>

type Entrada = string | number | null | undefined;
type Limpia = SinNulos<Entrada>;                             // string | number

const ok: Limpia = 42;
const mal: Limpia = null;                                    // error

Ábrelo en el editor y cámbialo →

tsc --strictcondicional.ts(7,7): error TS2322: El tipo 'null' no se puede asignar al tipo 'Limpia'.

El truco está en never, el conjunto vacío de la lección 2: en una unión, never desaparece, así que «devolver never» equivale a quitar ese miembro.

infer: sacar un tipo de dentro de otro

Dentro de la condición, infer declara un tipo que TypeScript debe deducir. Así se extrae el tipo de los elementos de un array o lo que hay dentro de una promesa; ReturnType y Awaited están hechos así:

type ElementoDe<T> = T extends (infer U)[] ? U : never;
type Desenvuelto<T> = T extends Promise<infer U> ? U : T;

type A = ElementoDe<string[]>;              // string
type B = Desenvuelto<Promise<number[]>>;    // number[]

const a: A = "texto";
const b: B = [1, 2, 3];
const c: A = 5;                             // error: A es string

Ábrelo en el editor y cámbialo →

tsc --strictinfer.ts(9,7): error TS2322: El tipo 'number' no se puede asignar al tipo 'string'.

Tipos de plantilla de texto

Las plantillas de JavaScript (`Hola, ${nombre}`) tienen su versión en tipos. Con uniones dentro, generan todas las combinaciones, y con ${number} admiten cualquier número en esa posición:

type Lado = "top" | "bottom";
type Borde = `border-${Lado}`;                 // "border-top" | "border-bottom"
type Medida = `${number}px` | `${number}%`;

function estilo(propiedad: Borde, valor: Medida): string {
  return `${propiedad}: ${valor};`;
}

console.log(estilo("border-top", "2px"));
console.log(estilo("border-bottom", "50%"));
estilo("border-left", "2px");    // error
estilo("border-top", "2em");     // error

Ábrelo en el editor y cámbialo →

tsc --strictplantillas.ts(11,8): error TS2345: No se puede asignar un argumento de tipo ""border-left"" al parámetro de tipo ""border-bottom" | "border-top"".
plantillas.ts(12,22): error TS2345: No se puede asignar un argumento de tipo ""2em"" al parámetro de tipo "Medida".

El error que te ahorra: escuchar un evento con el nombre mal escrito

import { EventEmitter } from "node:events";

const conexion = new EventEmitter();
conexion.on("conectdo", () => console.log("¡Conectado!"));   // errata
conexion.emit("conectado");
console.log("fin del programa");
node eventos_antes.mjsfin del programa

No hay error, solo silencio: el aviso nunca se muestra, porque nadie escucha el evento que se emite. Es uno de los fallos más difíciles de encontrar. Con un mapa de eventos y keyof, el nombre del evento deja de ser un texto cualquiera:

type Eventos = {
  conectado: () => void;
  mensaje: (texto: string) => void;
};

class Emisor {
  #oyentes = new Map<keyof Eventos, Array<(...args: never[]) => void>>();

  on<K extends keyof Eventos>(evento: K, oyente: Eventos[K]): void {
    this.#oyentes.set(evento, [...(this.#oyentes.get(evento) ?? []), oyente]);
  }
}

const conexion = new Emisor();
conexion.on("conectdo", () => console.log("¡Conectado!"));
conexion.on("mensaje", (texto) => console.log(texto.toUpperCase()));   // texto es string
tsc --stricteventos.ts(15,13): error TS2345: No se puede asignar un argumento de tipo ""conectdo"" al parámetro de tipo "keyof Eventos".

Por eso el addEventListener del navegador te avisa si escribes "clik": sus tipos usan exactamente este patrón. Lo verás en el ejemplo Tipar eventos del DOM.

as const: valores que no se ensanchan

En la lección 2 viste que let ensancha "oscuro" a string. Con los arrays y los objetos pasa siempre, aunque uses const, porque su contenido sí puede cambiar. as const lo impide: todo queda de solo lectura y con sus valores literales. Su uso estrella es definir la lista una sola vez y sacar de ella el tipo:

const ROLES = ["admin", "editor", "lector"] as const;
type Rol = (typeof ROLES)[number];          // "admin" | "editor" | "lector"

function puedeEditar(rol: Rol): boolean {
  return rol !== "lector";
}

for (const r of ROLES) console.log(r, puedeEditar(r));
puedeEditar("invitado");                    // error

Ábrelo en el editor y cámbialo →

tsc --strictas_const.ts(9,13): error TS2345: No se puede asignar un argumento de tipo ""invitado"" al parámetro de tipo ""admin" | "editor" | "lector"".

Si mañana añades un rol al array, el tipo Rol lo incluye solo, y además tienes la lista para recorrerla en ejecución, cosa que un tipo no permite. Es la alternativa habitual a los enum, que Node no sabe ejecutar (lección 1); hay un ejemplo completo en Alternativas a enum.

satisfies: comprobar sin perder detalle

Anotar una variable tiene un coste: su tipo pasa a ser exactamente el de la anotación, y se pierde lo que TypeScript sabía de más. satisfies, de TypeScript 4.9, comprueba que el valor cumple un tipo pero conserva el tipo deducido:

type Config = Record<string, string | number>;

const anotada: Config = { host: "localhost", puerto: 8080 };
const comprobada = { host: "localhost", puerto: 8080 } satisfies Config;

console.log(comprobada.puerto.toFixed(0));   // puerto es number
console.log(anotada.puerto.toFixed(0));      // error: para Config, puede ser string
const mala = { host: "localhost", puerto: true } satisfies Config;   // error

Ábrelo en el editor y cámbialo →

tsc --strictsatisfies.ts(7,28): error TS2339: La propiedad 'toFixed' no existe en el tipo 'string | number'.
  La propiedad 'toFixed' no existe en el tipo 'string'.
satisfies.ts(8,35): error TS2322: El tipo 'boolean' no se puede asignar al tipo 'string | number'.

Con la anotación, anotada.puerto es string | number, porque así lo dice Config; con satisfies, el compilador comprueba igual que todo cumple Config (la última línea falla), pero recuerda que puerto es un número.

Resumen

HerramientaFormaPara
Tipo mapeado{ [K in keyof T]: ... }transformar todas las propiedades
Renombrar claves[K in keyof T as ...]getters, eventos, prefijos
Tipo condicionalA extends B ? X : Yelegir o filtrar tipos
Deducir dentroinfer Usacar el tipo de un array o una promesa
Plantilla de texto`border-${Lado}`textos con formato fijo
No ensancharas constderivar uniones de una lista
Comprobar sin anotarsatisfies Tconservar el tipo exacto

Retos

Reto 1. Escribe un tipo mapeado Anulable<T> que permita null en todas las propiedades de T, y úsalo con un formulario de dos campos.

Ver la solución
type Anulable<T> = { [K in keyof T]: T[K] | null };

interface Formulario { nombre: string; edad: number }
const vacio: Anulable<Formulario> = { nombre: null, edad: null };
const medio: Anulable<Formulario> = { nombre: "Ana", edad: null };
console.log(vacio, medio);

Ábrelo en el editor y cámbialo →

salida{ nombre: null, edad: null } { nombre: 'Ana', edad: null }

Reto 2. Escribe type Parametros<F> que, dada una función, dé la tupla de sus parámetros (es lo que hace el utilitario Parameters). Pista: infer.

Ver la solución
type Parametros<F> = F extends (...args: infer A) => unknown ? A : never;

function mover(x: number, y: number, suave?: boolean) {}
type P = Parametros<typeof mover>;      // [x: number, y: number, suave?: boolean]

const ok: P = [10, 20];
const mal: P = [10, "20"];              // error

Ábrelo en el editor y cámbialo →

tsc --strictreto2.ts(7,21): error TS2322: El tipo 'string' no se puede asignar al tipo 'number'.

Reto 3. Con as const, define una tabla COLORES de nombres a códigos hexadecimales y deriva de ella el tipo NombreColor con las claves. Usa satisfies para comprobar que todos los valores empiezan por #.

Ver la solución
const COLORES = {
  acento: "#1e64af",
  tinta: "#0f1f33",
  borde: "#e3e8ee",
} as const satisfies Record<string, `#${string}`>;

type NombreColor = keyof typeof COLORES;     // "acento" | "tinta" | "borde"

function color(n: NombreColor): string {
  return COLORES[n];
}
console.log(color("acento"), color("borde"));

Ábrelo en el editor y cámbialo →

salida#1e64af #e3e8ee

Si un valor no empezara por #, satisfies lo marcaría; y as const mantiene los códigos exactos en vez de ensancharlos a string.

Todo el código de esta lección se comprobó con tsc 7.0.2 en modo estricto (--strict --locale es) y se ejecutó con Node.js 24.22 en un contenedor limpio antes de publicarla; los mensajes de error y las salidas están copiados de esa ejecución. ¿Quieres probar algo? Usa el editor de TypeScript.