Parte I · Fundamentos

1. JavaScript, TypeScript y Node.js en profundidad

Angular, NestJS y MikroORM son, en última instancia, JavaScript ejecutándose sobre un motor con un sistema de tipos encima. La inmensa mayoría de los errores «raros» de esos frameworks no son errores del framework: son malentendidos sobre el event loop, sobre las referencias, sobre this o sobre cómo el compilador de TypeScript borra los tipos. Este capítulo construye ese suelo firme.

CORE Tiempo de lectura: ~90 min Prerrequisitos: programación básica

1.1 Qué vas a poder hacer al terminar

1.2 Historia y contexto: de un lenguaje de 10 días al stack completo

Cronología esencial

1995. Brendan Eich escribe la primera versión de JavaScript en Netscape en unos diez días. El encargo era «algo parecido a Java pero para diseñadores». De ahí vienen a la vez sus mejores ideas (funciones de primera clase, objetos dinámicos, closures, herencia por prototipos, tomadas de Scheme y Self) y sus peores decisiones (coerción implícita, null y undefined, this dinámico, inserción automática de punto y coma).

1997–1999. Estandarización como ECMAScript. ES3 fija las bases.

2009 · ES5. strict mode, JSON, métodos funcionales de array. El mismo año Ryan Dahl presenta Node.js: JavaScript fuera del navegador sobre V8, con E/S no bloqueante.

2012 · TypeScript. Anders Hejlsberg (creador de Turbo Pascal, Delphi y C#) publica en Microsoft un superconjunto tipado de JavaScript. Su objetivo no es sustituir a JS, sino describir el JS que ya existe.

2015 · ES2015 (ES6). El salto más grande del lenguaje: let/const, clases, módulos, promesas, Map/Set, funciones flecha, desestructuración, template strings, Proxy y Reflect. A partir de aquí, una versión anual.

2016. Angular 2 se reescribe desde cero en TypeScript, lo que dispara su adopción. Un año después, Kamil Myśliwiec crea NestJS aplicando las mismas ideas al backend.

2017–2020. async/await, encadenamiento opcional, ??, campos privados de clase (#x), módulos ES nativos en Node.

2018 en adelante. MikroORM nace con la premisa de implementar en TypeScript los patrones Data Mapper, Identity Map y Unit of Work que Doctrine (PHP) e Hibernate (Java) habían popularizado.

La consecuencia práctica de esta historia es que el stack entero comparte ADN: los tres frameworks usan decoradores (una propuesta que nació en el ecosistema TypeScript/Angular), inyección de dependencias basada en metadatos de tipos y una arquitectura modular explícita. Entender el mecanismo una vez sirve para las tres capas.

1.3 Arquitectura interna: cómo se ejecuta tu código

1.3.1 El motor V8: del código fuente al código máquina

V8 es el motor de JavaScript de Chrome y de Node.js. No es un intérprete simple: es una máquina virtual con compilación en varias etapas.

  código fuente
        │
        ▼
  ┌───────────┐   AST    ┌──────────────┐  bytecode  ┌──────────────┐
  │  Parser   │─────────►│   Ignition   │───────────►│  ejecución   │
  │ (scanner) │          │ (intérprete) │            │  + perfilado │
  └───────────┘          └──────────────┘            └──────┬───────┘
                                                            │ código "caliente"
                                                            ▼
                          ┌──────────────┐          ┌───────────────────┐
                          │  Turbofan    │◄─────────│  optimizaciones   │
                          │ (JIT optim.) │          │  especulativas    │
                          └──────┬───────┘          └───────────────────┘
                                 │ si la suposición falla
                                 ▼
                          deoptimización → vuelve a bytecode

Conviene detenerse en cada fase, porque su vocabulario aparece constantemente en las herramientas de perfilado, en los informes de rendimiento y en las discusiones sobre optimización:

FaseQué haceConsecuencia práctica
Análisis (scanner y parser)Convierte el texto en tokens y estos en un árbol de sintaxis (AST). Hace pre-análisis perezoso: de las funciones que no se llaman de inmediato solo comprueba la sintaxis.El coste es proporcional al tamaño del bundle: se analiza también el 80 % que el usuario no usa. Por eso el lazy loading del capítulo 6 mejora el arranque tanto como reducir la red.
Ignition (intérprete)Genera bytecode compacto y lo ejecuta, anotando en un feedback vector qué tipos ha visto realmente en cada operación.Arranque rápido y poca memoria. Todo pasa por aquí; solo lo que se repite asciende de nivel.
Sparkplug (compilador base)Traduce el bytecode a código máquina sin optimizar y casi sin analizar.Compila en un tiempo despreciable y evita el salto brusco entre interpretar y optimizar.
Maglev y TurboFan (optimizadores)Cuando una función se vuelve «caliente», la recompilan especializándola: suponen que los tipos y las formas de objeto vistos hasta ahora se repetirán. Maglev es rápido y medio; TurboFan es lento y a fondo (inlining, eliminación de comprobaciones, escape analysis).Aquí está la mayor parte del rendimiento, y exige código predecible: mismos tipos, mismas formas, mismas rutas.
DesoptimizaciónSi una suposición falla (otra forma de objeto, un número que deja de ser entero, un undefined inesperado), se descarta el código optimizado y se vuelve al bytecode.Cara: se pierde el trabajo hecho y hay que volver a calentarse. Un bucle que desoptimiza en cada vuelta puede ir un orden de magnitud más lento.

Nada de esto es micro-optimización prematura: no se trata de escribir código raro para engañar al motor, sino de no sorprenderlo. Las prácticas que hacen el código legible (una clase con todos sus campos declarados, funciones que reciben siempre el mismo tipo, colecciones homogéneas) son justo las que permiten especializarlo. Las implicaciones prácticas, que desarrollamos en los tres apartados siguientes:

Fuga de memoria típica en Angular Un componente se destruye pero su suscripción a un observable global sigue viva; el closure del subscribe mantiene una referencia al componente, y con él a todo su árbol de vistas. El recolector no puede liberarlo. Solución: takeUntilDestroyed(), AsyncPipe o señales. Lo veremos en el capítulo 4.

1.3.2 Formas ocultas, caches en línea y polimorfismo

JavaScript permite añadir y borrar propiedades de un objeto en cualquier momento. Si el motor buscara cada propiedad en un diccionario, el acceso a tarea.estado costaría un cálculo de hash en cada lectura. Para evitarlo, V8 asocia a cada objeto una forma oculta (internamente la llama map; en la literatura se conoce como hidden class) que describe qué propiedades tiene y en qué posición de memoria está cada una. Los objetos que comparten forma comparten ese descriptor, y el acceso se convierte en una lectura por desplazamiento fijo, tan barata como en un lenguaje compilado.

Lo importante es que las formas se construyen por transiciones, en el orden en que asignas las propiedades. Dos objetos con las mismas propiedades pero asignadas en distinto orden acaban con formas distintas:

  const a = {};          FORMA_0 (objeto vacío)
  a.x = 1;      ──────►  FORMA_1  { x }
  a.y = 2;      ──────►  FORMA_2  { x, y }

  const b = {};          FORMA_0 (parte del mismo sitio)
  b.y = 2;      ──────►  FORMA_3  { y }          ← rama distinta del árbol
  b.x = 1;      ──────►  FORMA_4  { y, x }       ← ¡NO es FORMA_2!

  Resultado: a y b "parecen" iguales para ti, pero para el motor son dos tipos
  diferentes. Una función que reciba los dos verá dos formas y se especializará peor.

  const c = { x: 1, y: 2 };   ──────►  FORMA_2 directamente (literal completo)

Sobre esas formas se construyen las caches en línea (inline caches): en cada punto del código donde se lee una propiedad, el motor recuerda qué formas ha visto y qué desplazamiento correspondía. Según cuántas formas distintas pasen por ese punto, la cache tiene tres estados:

Estado de la cacheFormas vistasCoste del accesoEjemplo típico
Monomórfica1Óptimo: una comprobación y una lectura por desplazamientoUna función que solo recibe instancias de Task
Polimórfica2 a 4Aceptable: una pequeña lista de comprobacionesUn renderizador que atiende tres tipos de nodo
Megamórfica5 o másMalo: se abandona la cache y se consulta una tabla hash globalUn mapper genérico que recibe objetos literales de cualquier forma
mapper.tsINCORRECTO
// Cada fila produce una FORMA distinta según qué columnas vengan
// rellenas: hasta cuatro combinaciones para el mismo concepto.
function aTarea(fila: FilaSql) {
  const t: Record<string, unknown> = {};
  t.id = fila.id;
  if (fila.titulo) t.titulo = fila.titulo;      // condicional
  if (fila.fecha)  t.fecha  = new Date(fila.fecha);
  t.estado = fila.estado;                       // acaba en offsets distintos
  return t;
}

// El acceso a .estado se vuelve megamórfico, y encima se
// ha perdido por completo el tipado.
const pendientes = filas.map(aTarea).filter((t) => t.estado === 'pendiente');
mapper.tsCORRECTO
// Una única forma: todos los campos existen siempre, en el mismo
// orden, con null explícito cuando no hay valor.
class Tarea {
  constructor(
    readonly id: number,
    readonly titulo: string,
    readonly fecha: Date | null,
    readonly estado: Estado,
  ) {}
}

const aTarea = (f: FilaSql): Tarea =>
  new Tarea(f.id, f.titulo ?? '', f.fecha ? new Date(f.fecha) : null, f.estado);

// Acceso monomórfico, tipado completo y contrato explícito.
const pendientes = filas.map(aTarea).filter((t) => t.estado === 'pendiente');
El coste real de delete Borrar una propiedad con delete obj.x no solo cambia la forma: en muchos casos degrada el objeto a modo diccionario, del que ya no vuelve. Si necesitas «quitar» un campo, crea un objeto nuevo sin él (const { x, ...resto } = obj) o asígnale null. La misma regla se aplica a los arrays: delete arr[3] deja un hueco y convierte el array en sparse, con lo que pierde la representación compacta y se ralentiza todo recorrido posterior.

1.3.3 La pila y el montículo

La memoria de un programa JavaScript se reparte entre dos zonas con reglas muy distintas. La pila (stack) guarda los marcos de llamada: los argumentos, las variables locales y la dirección de retorno de cada función en curso. El montículo (heap) guarda los objetos, arrays, funciones y closures. En la pila viven los valores primitivos y las referencias; los objetos a los que apuntan esas referencias viven siempre en el montículo.

  function total(items) {          ┌──────────── PILA (stack) ─────────────┐
    const n = items.length;        │  marco de total()                     │
    return n * 2;                  │    items ────────┐   (referencia)     │
  }                                │    n = 3         │                    │
                                   ├──────────────────┼────────────────────┤
  const lista = [1, 2, 3];         │  marco global    │                    │
  const t = total(lista);          │    lista ────────┤   (referencia)     │
                                   │    t = 6         │   (primitivo)      │
                                   └──────────────────┼────────────────────┘
                                                      │
                                   ┌──────────────────▼ MONTÍCULO (heap) ──┐
                                   │   [1, 2, 3]   ← UN solo array         │
                                   │   apuntado por DOS referencias        │
                                   └───────────────────────────────────────┘

  · La pila es pequeña (del orden de 1 MB por hilo) y se libera sola al volver
    de cada función: no interviene el recolector de basura.
  · El montículo es grande (varios GB) y lo gestiona el recolector.
  · Cuando "pasas un objeto" a una función, copias la REFERENCIA, no el objeto.

De este reparto salen dos consecuencias que verás en producción. La primera es el desbordamiento de pila: como cada llamada añade un marco y la pila tiene un tamaño fijo, una recursión sin caso base (o simplemente demasiado profunda) produce RangeError: Maximum call stack size exceeded. JavaScript no garantiza optimización de llamadas de cola en la práctica, así que para recorridos profundos conviene una solución iterativa con una pila explícita en el montículo. La segunda es que pasar objetos es barato sin importar su tamaño (se copia una referencia de 8 bytes), lo que explica por qué copiar defensivamente estructuras grandes en cada llamada es una decisión que hay que tomar con criterio y no por costumbre.

1.3.4 Recolección de basura generacional

El recolector de V8 se apoya en la hipótesis generacional: la inmensa mayoría de los objetos muere joven. Un objeto intermedio de un map, el resultado de un JSON.parse temporal o un DTO de respuesta viven milisegundos; una conexión a base de datos o una caché viven horas. Tratar a ambos igual sería un desperdicio, así que el montículo se divide en generaciones.

  ┌──────────────────────── MONTÍCULO ──────────────────────────────────┐
  │ ESPACIO JOVEN  (young generation, decenas de MB)                    │
  │                                                                     │
  │   ┌──────────────┐   se copian los supervivientes  ┌─────────────┐  │
  │   │  desde       │ ───────────────────────────────►│  hacia      │  │  SCAVENGER
  │   │  (from-space)│   el resto se descarta en bloque│  (to-space) │  │  (paradas de
  │   └──────────────┘                                 └─────────────┘  │   ~1 ms)
  │            │ sobrevive a dos recolecciones                          │
  │            ▼  PROMOCIÓN                                             │
  ├─────────────────────────────────────────────────────────────────────┤
  │ ESPACIO VIEJO  (old generation, hasta --max-old-space-size)         │
  │   marcado concurrente ─► barrido ─► compactación                    │  MARK-COMPACT
  │   recorre TODO lo alcanzable desde las raíces (variables globales,  │  (paradas más
  │   pila, contextos vivos): cuanto más grande, más caro               │   largas)
  └─────────────────────────────────────────────────────────────────────┘

  RAÍCES ──► objeto A ──► objeto B ──► objeto C      = alcanzable, NO se libera
             objeto D  (nadie lo referencia)         = basura, se libera

Lo que hay que retener como programador de aplicaciones es lo siguiente:

1.4 El event loop en profundidad

1.4.1 Un hilo, varias colas: el modelo básico

JavaScript tiene un solo hilo de ejecución. Todo el trabajo asíncrono lo delega al entorno (el navegador o libuv en Node) y recoge los resultados mediante colas. El bucle de eventos es el planificador que decide qué se ejecuta y cuándo.

   ┌─────────────────────────────┐
   │        CALL STACK           │  ← una sola cosa a la vez
   └──────────────┬──────────────┘
                  │ vacío?
                  ▼
   ┌─────────────────────────────────────────────┐
   │  1) COLA DE MICROTAREAS (se vacía ENTERA)   │  Promise.then, queueMicrotask,
   │     antes de pasar a la siguiente macro     │  await, MutationObserver
   └──────────────┬──────────────────────────────┘
                  ▼
   ┌─────────────────────────────────────────────┐
   │  2) UNA macrotarea (task)                   │  setTimeout, setInterval, E/S,
   │                                             │  eventos del DOM, setImmediate
   └──────────────┬──────────────────────────────┘
                  ▼
   ┌─────────────────────────────────────────────┐
   │  3) (navegador) render: estilo, layout,     │  requestAnimationFrame va
   │     paint, composición ~cada 16,6 ms        │  justo antes del render
   └─────────────────────────────────────────────┘
                  │
                  └──── vuelve a 1
event-loop.ts · ¿en qué orden se imprime?
console.log('1 · síncrono');

setTimeout(() => console.log('2 · macrotarea (timeout 0)'), 0);

Promise.resolve().then(() => console.log('3 · microtarea'));

queueMicrotask(() => console.log('4 · microtarea explícita'));

(async () => {
  console.log('5 · síncrono (el cuerpo de una async corre hasta el primer await)');
  await null;                       // aquí cede el control: lo de abajo es microtarea
  console.log('6 · microtarea (continuación tras await)');
})();

console.log('7 · síncrono');

// SALIDA:
// 1 · síncrono
// 5 · síncrono (...)
// 7 · síncrono
// 3 · microtarea
// 4 · microtarea explícita
// 6 · microtarea (continuación tras await)
// 2 · macrotarea (timeout 0)

Explicación línea por línea. Primero se ejecuta todo el código síncrono del script (líneas 1, 5 y 7): el cuerpo de una función async se ejecuta de forma síncrona hasta que encuentra el primer await. Al llegar al await, la continuación de la función se encola como microtarea. Cuando la pila queda vacía, el motor vacía toda la cola de microtareas en orden de llegada (3, 4, 6). Solo entonces toma una macrotarea (2). Por eso un setTimeout(fn, 0) nunca se ejecuta «inmediatamente»: se ejecuta después de todas las promesas pendientes.

Trampa: bloqueo por microtareas infinitas Si una microtarea encola otra microtarea sin fin, el bucle nunca llega a las macrotareas ni al render: la página se congela aunque el código sea «asíncrono». La asincronía no da concurrencia real; solo reordena el trabajo en el mismo hilo.

1.4.2 Microtareas frente a macrotareas

La distinción entre tarea (o macrotarea, task en la especificación) y microtarea es la única regla que hay que memorizar para predecir el orden de ejecución de cualquier programa. Una macrotarea es una unidad de trabajo que el bucle toma de una cola y ejecuta hasta el final; una microtarea es un trabajo pequeño que debe ejecutarse antes de que el bucle continúe.

OrigenTipoEntornoNota
Promise.then/catch/finallyMicrotareaAmbosIncluye la continuación de cada await
queueMicrotask()MicrotareaAmbosForma explícita y recomendada de encolar una
process.nextTick()Cola propia, antes de las microtareasSolo NodeTiene prioridad sobre las promesas
MutationObserverMicrotareaSolo navegadorCambios del DOM agrupados
setTimeout / setIntervalMacrotareaAmbosEl retardo es un mínimo, no una garantía
setImmediate()Macrotarea (fase check)Solo NodeSe ejecuta tras la fase de poll del mismo ciclo
Callbacks de E/S (red, ficheros)Macrotarea (fase poll)AmbosEl grueso del trabajo de un servidor
Eventos del DOM, postMessageMacrotareaSolo navegadorUn click es una tarea completa
requestAnimationFrameCola propia, antes del pintadoSolo navegadorSe sincroniza con el refresco de pantalla

Con esa tabla, las tres reglas que gobiernan todo el modelo son:

  1. Una tarea se ejecuta hasta el final. Nada la interrumpe: ni un temporizador, ni un evento, ni el repintado del navegador. Este es el motivo de que bloquear el hilo sea tan grave.
  2. Al terminar cada tarea se vacía entera la cola de microtareas, no una sola microtarea.
  3. Las microtareas encoladas mientras se vacía la cola se procesan en el mismo vaciado, no en el siguiente. De ahí que una cadena infinita de microtareas congele el proceso.
orden.ts · predice la salida antes de leer el final
console.log('A');

setTimeout(() => {
  console.log('B');
  Promise.resolve().then(() => console.log('C'));   // microtarea DENTRO de una macrotarea
}, 0);

setTimeout(() => console.log('D'), 0);

Promise.resolve()
  .then(() => console.log('E'))
  .then(() => console.log('F'));

console.log('G');

// SALIDA: A G E F B C D
//
// 1. Síncrono: A, G.
// 2. Se vacía la cola de microtareas: E; el segundo .then se encola DURANTE
//    el vaciado y se procesa en el mismo vaciado: F.
// 3. Primera macrotarea: B. Al terminar ESA tarea se vacía otra vez la cola
//    de microtareas: C (¡antes de la segunda macrotarea!).
// 4. Segunda macrotarea: D.
Analogía: la ventanilla y los recados Una ventanilla con un solo funcionario: cada persona de la cola es una macrotarea y se la atiende entera. Las microtareas son los recados que el funcionario se apunta mientras atiende; al despedir a una persona hace todos los recados pendientes, incluidos los que se apuntó haciendo recados, y solo entonces llama al siguiente. Si un recado genera otro sin fin, la cola de personas no avanza jamás.

1.4.3 Las fases del bucle de Node y libuv

En Node el bucle tiene fases explícitas implementadas por libuv, y existe un thread pool (por defecto 4 hilos) para operaciones que no se pueden hacer de forma no bloqueante (sistema de archivos, DNS, criptografía, compresión).

   ┌───────────────────────────┐
┌─►│  timers                   │  callbacks de setTimeout / setInterval
│  ├───────────────────────────┤
│  │  pending callbacks        │  callbacks de E/S diferidos
│  ├───────────────────────────┤
│  │  idle, prepare            │  uso interno
│  ├───────────────────────────┤
│  │  poll                     │  ← aquí espera la E/S; el grueso del trabajo
│  ├───────────────────────────┤
│  │  check                    │  setImmediate
│  ├───────────────────────────┤
│  │  close callbacks          │  socket.on('close')
│  └───────────┬───────────────┘
└──────────────┘
   Entre CADA fase se vacían: process.nextTick() y luego las microtareas de promesas.

   THREAD POOL (libuv, 4 hilos por defecto, UV_THREADPOOL_SIZE)
   fs.*, crypto.pbkdf2, zlib, dns.lookup → no bloquean el hilo principal

Cada fase tiene una cola propia de callbacks y el bucle la vacía por completo (con un límite para no quedarse atrapado) antes de pasar a la siguiente. Esto es lo que hace cada una:

FaseQué ejecutaQué debes saber
timersCallbacks de setTimeout y setInterval ya vencidos.El retardo es un mínimo: con el bucle ocupado, un setTimeout(fn, 10) puede saltar a los 400 ms. No sirven para medir tiempo.
pending callbacksCallbacks de sistema aplazados del ciclo anterior, típicamente errores de TCP.Fase interna: casi nunca la observarás.
idle, prepareUso interno de libuv.No es accesible desde JavaScript.
pollEspera y atiende la E/S: peticiones entrantes, respuestas de la base de datos, lecturas terminadas. Es donde el proceso pasa casi todo el tiempo.Si no hay nada pendiente y hay setImmediate en cola, sale hacia check; si no, espera sin consumir CPU hasta el próximo evento o timer.
checkCallbacks de setImmediate.Significa «ejecuta esto tras la E/S de este ciclo, antes de los timers del siguiente».
close callbacksEventos close de sockets y manejadores destruidos.Sitio natural para liberar los recursos de una conexión.
Las dos colas que se cuelan entre fase y fase Después de cada callback (y, por tanto, entre fases) Node vacía primero la cola de process.nextTick() y después la de microtareas de promesas. Son colas que no pertenecen al bucle: se procesan siempre que la pila queda vacía. Por eso process.nextTick tiene más prioridad que Promise.then, y por eso un nextTick recursivo puede impedir que el bucle avance de fase (inanición del bucle).

1.4.4 setTimeout, setImmediate y process.nextTick

Estas tres funciones parecen intercambiables y no lo son. La diferencia se entiende leyendo el diagrama de fases: setTimeout entra en la fase timers (al principio del ciclo), setImmediate en la fase check (después de la E/S del ciclo actual) y process.nextTick no entra en ninguna fase, sino en una cola que se vacía en cuanto la pila queda libre.

planificacion.ts · predice la salida de los tres bloques
import { readFile } from 'node:fs';

// ---------- BLOQUE 1: en el módulo principal, el orden NO es determinista ----------
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Puede imprimir "timeout, immediate" o "immediate, timeout". Depende de si el
// arranque del proceso ha tardado más o menos de 1 ms: si al entrar en la fase
// de timers el plazo ya ha vencido, gana el timeout; si no, gana el immediate.
// Moraleja: no escribas lógica que dependa de esta carrera.

// ---------- BLOQUE 2: dentro de un callback de E/S, SÍ es determinista ----------
readFile(__filename, () => {
  setTimeout(() => console.log('io: timeout'), 0);
  setImmediate(() => console.log('io: immediate'));
});
// SIEMPRE: "io: immediate" y después "io: timeout".
// Estamos ejecutando en la fase poll; la siguiente fase del MISMO ciclo es
// check (setImmediate), mientras que timers no llega hasta el ciclo siguiente.

// ---------- BLOQUE 3: prioridad de nextTick sobre las promesas ----------
setTimeout(() => console.log('4 · timeout'), 0);
setImmediate(() => console.log('5 · immediate'));
Promise.resolve().then(() => console.log('3 · promesa'));
process.nextTick(() => console.log('2 · nextTick'));
console.log('1 · síncrono');
// SALIDA: 1, 2, 3, y después 4 y 5 (en ese orden si venimos de E/S).
Cuándo NO usar process.nextTick Su cola se vacía por completo antes de que el bucle pueda avanzar, así que una función que se reencola a sí misma con nextTick deja al servidor sin atender peticiones sin que la CPU parezca saturada. Si lo que quieres es «ceder el control y seguir luego», usa setImmediate. Reserva nextTick para el caso para el que fue diseñado: permitir que el llamador registre sus listeners antes de emitir un evento en el constructor de una clase.

1.4.5 El bucle del navegador frente al de Node

Ambos entornos comparten el motor y el modelo de colas, pero el bucle no es el mismo objeto: en el navegador lo implementa el proceso de renderizado, y su gran diferencia es que entre tarea y tarea puede haber un ciclo de pintado. En Node no hay nada que pintar, y a cambio hay fases explícitas para la E/S.

  NAVEGADOR                                  NODE.JS (libuv)
  ─────────────────────────────────          ──────────────────────────────────
  1. una tarea (evento, timer, red)          1. fase timers
  2. vaciar microtareas                      2. fase pending callbacks
  3. requestAnimationFrame                   3. fase poll        ← espera la E/S
  4. estilo → layout → paint → composite     4. fase check       ← setImmediate
     (~cada 16,6 ms a 60 Hz)                 5. fase close callbacks
  5. (si sobra tiempo) requestIdleCallback
     ↑                                          ↑
     └── vuelve a 1                             └── vuelve a 1

  ENTRE CADA PASO, EN AMBOS: se vacía la cola de microtareas.
  Solo en Node: antes de las microtareas se vacía la cola de process.nextTick.

  Consecuencia visible:
  · Navegador → bloquear el hilo 200 ms = un fotograma perdido, interfaz congelada.
  · Node      → bloquear el hilo 200 ms = TODAS las peticiones en curso esperan 200 ms.

Dos APIs existen solo en el navegador: requestAnimationFrame, que ejecuta el callback justo antes del siguiente pintado (el sitio correcto para animaciones y medidas de layout), y requestIdleCallback, para trabajo no urgente. Del otro lado, setImmediate y process.nextTick solo existen en Node: si escribes código compartido entre servidor y cliente (capítulo 7), no dependas de ninguno de los cuatro.

1.4.6 Qué significa exactamente «bloquear el event loop»

Bloquear el bucle es ejecutar código síncrono durante un tiempo apreciable. No hay más misterio: como una tarea se ejecuta hasta el final, mientras tu función esté en la pila, ningún callback pendiente, ningún temporizador y ninguna petición entrante puede atenderse. En un navegador eso es una interfaz congelada; en un servidor es una caída de rendimiento que afecta a todos los usuarios a la vez y que en los paneles de monitorización aparece como latencia alta con CPU baja, lo que despista mucho.

La regla de oro del backend en Node Nunca bloquees el event loop. Un bucle pesado, un JSON.parse de 50 MB, un bcrypt.hashSync o una expresión regular con retroceso catastrófico congelan todas las peticiones concurrentes del proceso, no solo la actual. Para trabajo intensivo de CPU: worker_threads, una cola (BullMQ, capítulo 11) o un servicio aparte.
hash.service.tsINCORRECTO
import * as bcrypt from 'bcrypt';

hash(password: string): string {
  // Bloquea el ÚNICO hilo ~100 ms por llamada.
  // Con 50 logins simultáneos: 5 segundos de API congelada.
  return bcrypt.hashSync(password, 12);
}
hash.service.tsCORRECTO
import * as bcrypt from 'bcrypt';

async hash(password: string): Promise<string> {
  // La versión asíncrona delega en el thread pool de libuv:
  // el event loop sigue atendiendo otras peticiones.
  return bcrypt.hash(password, 12);
}

La ventaja de este problema es que se mide. El siguiente programa demuestra el bloqueo de forma reproducible: un temporizador que debería dispararse cada 100 ms se retrasa exactamente lo que dura el trabajo síncrono.

bloqueo.ts · demostración medible
// Un "latido" que debería sonar cada 100 ms clavados.
let anterior = Date.now();
const latido = setInterval(() => {
  const ahora = Date.now();
  console.log(`latido con ${ahora - anterior - 100} ms de retraso`);
  anterior = ahora;
}, 100);

// Trabajo síncrono de 800 ms: nada lo puede interrumpir.
function trabajoPesado(ms: number): void {
  const fin = Date.now() + ms;
  while (Date.now() < fin) { /* quemando CPU en el hilo principal */ }
}

setTimeout(() => trabajoPesado(800), 350);

// SALIDA aproximada: tres latidos puntuales, uno con ~750 ms de retraso
// (el que coincide con trabajoPesado) y vuelta a la normalidad.
// Durante esos 800 ms el proceso no responde a ninguna petición HTTP,
// no lee del socket de la base de datos y no cierra conexiones.

setTimeout(() => clearInterval(latido), 2000);

En producción esto no se mide con console.log, sino con el histograma que ofrece el propio Node: monitorEventLoopDelay({ resolution: 20 }) de node:perf_hooks se activa con enable() y expone mean y percentile(99) en nanosegundos. Publicar ese percentil como métrica es una de las formas más baratas de vigilar un servicio: si sube, hay trabajo síncrono donde no debería haberlo.

Operación síncrona frecuenteOrden de magnitudAlternativa correcta
JSON.parse / JSON.stringify de varios MBDecenas o centenas de msStreaming del cuerpo, paginación, o mover la serialización a un worker
bcrypt.hashSync con coste 12Centenas de ms por llamadaVersión asíncrona (usa el thread pool)
fs.readFileSync de un fichero grandeDecenas de ms o másfs.promises o streams con contrapresión
Expresión regular con retroceso catastróficoDe ms a minutos según la entradaReescribir la expresión, limitar la longitud de la entrada, usar un validador
Bucle sobre cientos de miles de filas en memoriaCentenas de msHacer el trabajo en SQL (capítulo 19) o por lotes con setImmediate
Generar un PDF, un ZIP o una imagenSegundosCola de trabajos en segundo plano (capítulo 11) o worker_threads

1.5 Valores, referencias e igualdad

JavaScript tiene siete tipos primitivos (string, number, boolean, null, undefined, symbol, bigint) y un tipo de referencia (object, que incluye arrays, funciones, fechas, mapas...). Los primitivos se copian por valor; los objetos, por referencia.

referencias.ts
const a = { n: 1 };
const b = a;          // b apunta al MISMO objeto, no es una copia
b.n = 2;
console.log(a.n);     // 2   ← a "cambió" porque nunca hubo dos objetos

const c = { ...a };   // copia SUPERFICIAL (shallow): un objeto nuevo de primer nivel
c.n = 3;
console.log(a.n);     // 2   ← ahora sí son independientes

const usuario = { nombre: 'Ana', dir: { ciudad: 'Madrid' } };
const copia = { ...usuario };
copia.dir.ciudad = 'Valencia';
console.log(usuario.dir.ciudad);   // 'Valencia'  ← ¡la copia superficial comparte dir!

// Copia profunda nativa (Node 17+ y navegadores modernos)
const profunda = structuredClone(usuario);
profunda.dir.ciudad = 'Sevilla';
console.log(usuario.dir.ciudad);   // 'Valencia'  ← ahora sí es independiente
Por qué esto es crítico en Angular y en MikroORM

Angular: con OnPush y con señales, la detección de cambios compara referencias. Si mutas un array (lista.push(x)) la referencia no cambia y la vista no se actualiza. Hay que crear un valor nuevo: lista.update(l => [...l, x]).

MikroORM: ocurre justo lo contrario y es igual de importante. El Identity Map garantiza que la misma fila sea el mismo objeto dentro de un contexto, y el Unit of Work detecta cambios comparando el objeto actual con una instantánea original. Ahí la mutación sí es el mecanismo esperado.

1.5.1 Igualdad: ==, === y Object.is

Existen tres algoritmos de comparación y conviene saber cuál usa cada operador. === es igualdad estricta: si los tipos difieren, devuelve false sin más. == es igualdad laxa: si los tipos difieren, aplica un algoritmo de conversión (ToPrimitive y después conversión a número) antes de comparar, y ese algoritmo produce resultados que nadie recuerda de memoria. Object.is es la comparación «SameValue», idéntica a === salvo en dos casos: NaN y la distinción entre 0 y -0.

ExpresiónResultadoMotivo
0 == '0'trueCoerción: la cadena se convierte a número
0 == []true[]''0
0 == ''trueLa cadena vacía se convierte a 0
'0' == falsetrueAmbos lados acaban convertidos a número
[] == ![]true![] es false0, y []0
null == 0falsenull solo es laxamente igual a undefined
null == undefinedtrueCaso especial de la especificación
null === undefinedfalseTipos distintos
NaN === NaNfalseNaN no es igual a nada, ni a sí mismo
Object.is(NaN, NaN)trueIgualdad «SameValue»
Object.is(0, -0)falseSameValue distingue los dos ceros; === no
{} === {}falseDos referencias distintas
[1,2] == '1,2'trueEl array se convierte a cadena con join(',')

Regla profesional: usa siempre ===. La única excepción tolerada es x == null para comprobar «null o undefined» de una vez, aunque en TypeScript moderno es preferible x ?? valor o una comprobación explícita.

La igualdad estructural no existe en JavaScript Ningún operador compara dos objetos por contenido: {a:1} === {a:1} es false, e includes o indexOf comparan por referencia. Si necesitas comparar por valor, escribe una función explícita o compara un identificador. Esta ausencia explica por qué en Angular hay que dar una expresión track cuando la lista se reconstruye en cada respuesta del servidor.

1.5.2 Valores falsy y el operador ??

Son falsy: false, 0, -0, 0n, '', null, undefined, NaN. Todo lo demás es truthy, incluidos [], {} y '0'.

config.tsINCORRECTO
// Si el usuario configura 0 reintentos o cadena vacía,
// || los descarta por ser falsy y aplica el valor por defecto.
const reintentos = opciones.reintentos || 3;   // 0 → 3  ¡mal!
const prefijo    = opciones.prefijo   || '/api';  // '' → '/api'  ¡mal!
config.tsCORRECTO
// ?? solo sustituye null y undefined: respeta 0 y ''
const reintentos = opciones.reintentos ?? 3;   // 0 → 0  correcto
const prefijo    = opciones.prefijo   ?? '/api';  // '' → '' correcto

1.5.3 Números, precisión y bigint

En JavaScript todos los números son de coma flotante de doble precisión (IEEE 754 de 64 bits), incluidos los que parecen enteros. De ahí salen dos problemas muy concretos en aplicaciones de gestión, que son exactamente las que construimos en este libro.

numeros.ts
// PROBLEMA 1: fracciones que no se pueden representar en binario
console.log(0.1 + 0.2);                 // 0.30000000000000004  (=== 0.3 es false)
console.log(19.99 * 100);               // 1998.9999999999998   ← al pasar a céntimos

// Solución para dinero: enteros en la unidad mínima, o un tipo decimal en la base
// de datos y una librería decimal en el código. Se formatea solo al mostrar.
const totalCentimos = 1999 * 3;                          // 5997, exacto
const paraMostrar   = (totalCentimos / 100).toFixed(2);  // '59.97'

// PROBLEMA 2: enteros grandes. El límite seguro es 2^53 - 1.
console.log(Number.MAX_SAFE_INTEGER);               // 9007199254740991
console.log(9007199254740993 === 9007199254740992); // true  ← ¡se pierden IDs!

const idGrande = 9007199254740993n;     // bigint: un BIGINT de PostgreSQL no cabe en number
console.log(idGrande + 1n);             // 9007199254740994n  (mezclar con number lanza TypeError)
Number.isSafeInteger(2 ** 53);          // false
Consecuencia directa en el stack Si una columna es BIGINT, el controlador de PostgreSQL la devuelve como cadena para no perder precisión; declararla como number en la entidad corrompe los identificadores en silencio. Con JSON.parse pasa lo mismo, porque JSON no tiene bigint. Regla práctica: los identificadores grandes y el dinero viajan como cadena en las APIs (capítulos 14 y 19).

1.6 Objetos, prototipos, clases y this

1.6.1 Herencia por prototipos

JavaScript no tiene clases en su núcleo: tiene objetos que enlazan a otros objetos. Cuando accedes a una propiedad, el motor recorre la cadena de prototipos hasta encontrarla o llegar a null. Las clases de ES2015 son azúcar sintáctico sobre este mecanismo.

  const perro = new Perro('Toby');

  perro ──[[Prototype]]──► Perro.prototype ──[[Prototype]]──► Animal.prototype ──► Object.prototype ──► null
   │                            │                                  │                      │
   nombre: 'Toby'            ladrar()                          comer()               toString()
                                                                                     hasOwnProperty()
clases.ts
abstract class Animal {
  // "parameter property": declara y asigna this.nombre en una línea
  constructor(protected readonly nombre: string) {}

  comer(): string { return `${this.nombre} come`; }

  // método abstracto: obliga a las subclases a implementarlo
  abstract sonido(): string;
}

class Perro extends Animal {
  #chip: string;                    // campo privado REAL de JavaScript (no accesible fuera)

  constructor(nombre: string, chip: string) {
    super(nombre);                  // obligatorio antes de usar this
    this.#chip = chip;
  }

  sonido(): string { return 'Guau'; }
  get identificador(): string { return this.#chip; }
}

const toby = new Perro('Toby', 'ES-001');
console.log(toby.comer(), toby.sonido());   // Toby come Guau
console.log(toby instanceof Animal);        // true
private de TypeScript vs #campo de JavaScript private es una comprobación solo en tiempo de compilación: en el JavaScript generado la propiedad es pública y accesible con obj['x']. #campo es privacidad real en runtime. Para lógica de seguridad, usa #; para el resto, private es suficiente y más idiomático en Angular/Nest.

1.6.2 this: la fuente número uno de errores

En una función normal, this se determina en el momento de la llamada, no donde se define. En una función flecha, this se hereda léxicamente del ámbito que la contiene y no se puede reasignar.

this-perdido.tsINCORRECTO
class Contador {
  private valor = 0;

  incrementar() {
    this.valor++;                 // ¿qué es this aquí?
  }

  registrar(boton: HTMLButtonElement) {
    // Se pasa la FUNCIÓN, se pierde el objeto:
    // al invocarla, this será el botón (o undefined en strict).
    boton.addEventListener('click', this.incrementar);
    // TypeError: Cannot read properties of undefined (reading 'valor')
  }
}
this-atado.tsCORRECTO
class Contador {
  private valor = 0;

  // Opción A: función flecha como campo de clase.
  // Captura this al construir la instancia.
  incrementar = () => { this.valor++; };

  registrar(boton: HTMLButtonElement) {
    boton.addEventListener('click', this.incrementar);

    // Opción B: envolver en una flecha en el punto de uso
    boton.addEventListener('click', () => this.incrementar());

    // Opción C: bind explícito
    boton.addEventListener('click', this.incrementar.bind(this));
  }
}
Analogía this es como el pronombre «yo» en una frase: su significado depende de quién la pronuncia, no de dónde está escrita. Una función flecha es una cita textual: conserva el «yo» del autor original.

1.6.3 Las cuatro reglas de this

Cuando en una entrevista te pregunten «¿cuánto vale this?», la respuesta correcta es «depende de cómo se llame a la función», y existen exactamente cuatro formas de llamarla. Se aplican en este orden de prioridad:

#ReglaForma de la llamadaValor de this
1Con newnew Persona()El objeto recién creado
2Enlace explícitof.call(o), f.apply(o), f.bind(o)()El objeto que pasas
3Enlace implícitoobj.metodo()El objeto que está a la izquierda del punto
4Por defectof()undefined en modo estricto (y en módulos); el objeto global si no

Las funciones flecha están fuera de esta clasificación: no tienen this propio, así que lo resuelven mirando el ámbito donde fueron escritas. Ninguna de las cuatro reglas les afecta, ni siquiera bind.

this-reglas.ts
'use strict';

const usuario = {
  nombre: 'Ana',
  saludar() { return `Hola, soy ${this.nombre}`; },
};

console.log(usuario.saludar());          // regla 3 → 'Hola, soy Ana'

// El error clásico: se extrae el método y se pierde el objeto de la izquierda.
const suelto = usuario.saludar;
// console.log(suelto());                // regla 4 → TypeError: this is undefined

console.log(suelto.call({ nombre: 'Luis' }));   // regla 2 → 'Hola, soy Luis'
console.log(suelto.apply({ nombre: 'Eva' }));   // igual, pero con array de argumentos
const atado = suelto.bind(usuario);
console.log(atado());                           // regla 2 → 'Hola, soy Ana'

// bind es permanente: no se puede volver a atar
console.log(atado.call({ nombre: 'Otro' }));    // sigue siendo 'Ana'

// Trampa habitual: un método pasado como callback pierde el enlace.
[1, 2].forEach(usuario.saludar);                // this === undefined
[1, 2].forEach(usuario.saludar, usuario);       // forEach admite thisArg
[1, 2].forEach(() => usuario.saludar());        // opción legible y recomendada

// Diferencia clave dentro de una clase
class Reloj {
  hora = '12:00';
  conMetodo() { return function () { return this?.hora; }; }   // this dinámico → undefined
  conFlecha() { return () => this.hora; }                      // this léxico  → '12:00'
}
Por qué esto aparece en Angular y en NestJS constantemente Cada vez que registras un método como callback (un manejador de eventos, un subscribe, un listener de proceso, un setInterval) estás aplicando la regla 4 sin darte cuenta. En componentes de Angular y en servicios de Nest, la solución idiomática es usar funciones flecha, que además documentan visualmente que el this es el del contexto que las rodea. El mismo problema, con otro disfraz, aparece cuando extraes un método de un servicio para pasarlo a un useFactory: pásalo envuelto en una flecha.

1.7 Closures y ámbito

1.7.1 Qué captura exactamente un closure

Un closure es una función junto con el entorno léxico en el que se creó. Es el mecanismo que permite estado privado, memoización, fábricas de funciones y, en la práctica, casi todos los patrones funcionales del stack.

closures.ts
// 1) Estado privado sin clases
function crearContador(inicial = 0) {
  let valor = inicial;                       // vive mientras viva alguna función interna
  return {
    incrementar: () => ++valor,
    leer: () => valor,
  };
}
const c = crearContador();
c.incrementar();
console.log(c.leer());        // 1
// console.log(c.valor);      // undefined: 'valor' es inalcanzable desde fuera

// 2) Memoización genérica: patrón real de caché en memoria
function memoizar<A extends unknown[], R>(fn: (...args: A) => R) {
  const cache = new Map<string, R>();        // capturado por el closure
  return (...args: A): R => {
    const clave = JSON.stringify(args);
    if (!cache.has(clave)) cache.set(clave, fn(...args));
    return cache.get(clave)!;
  };
}

// 3) El clásico error de var en bucles
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i), 0);  // 3 3 3
for (let j = 0; j < 3; j++) setTimeout(() => console.log(j), 0);  // 0 1 2
// var tiene ámbito de función: las tres flechas comparten la MISMA variable.
// let tiene ámbito de bloque: cada iteración crea un enlace nuevo.
Closures y fugas de memoria Un closure mantiene vivo todo el entorno que captura. Si dentro de un setInterval o de una suscripción capturas this de un componente pesado, ese componente no se liberará nunca. Captura solo lo mínimo necesario y cancela siempre lo que registres.

1.7.2 La cadena de ámbitos, el hoisting y la zona muerta temporal

Un closure funciona porque el motor no busca las variables donde se ejecuta la función, sino donde se escribió. Eso se llama ámbito léxico, y forma una cadena: si un identificador no está en el ámbito actual, se busca en el que lo contiene, y así hasta el global.

  ┌─ ÁMBITO GLOBAL ──────────────────────────────────────────┐
  │  const IVA = 0.21                                        │
  │  ┌─ ÁMBITO DE crearFactura() ─────────────────────────┐  │
  │  │  const lineas = []                                 │  │
  │  │  ┌─ ÁMBITO DE anadir(precio) ──────────────────┐   │  │
  │  │  │  const total = precio * (1 + IVA)           │   │  │
  │  │  └──────┬──────────────┬──────────────┬────────┘   │  │
  │  └─────────┼──────────────┼──────────────┼────────────┘  │
  └────────────┼──────────────┼──────────────┼───────────────┘
       'precio' es propio     │       'IVA' está dos niveles arriba
                     'lineas' está un nivel arriba

  La búsqueda va SIEMPRE hacia fuera y depende de dónde está ESCRITO el código, no
  de quién llama a la función. Ese es el secreto del closure: la función se lleva
  consigo la cadena de ámbitos de su definición.

El hoisting (elevación) es el otro fenómeno del que hay que hablar. Antes de ejecutar un ámbito, el motor registra todas sus declaraciones. Lo que cambia entre var, let, const, function y class no es si se elevan (todas se registran), sino en qué estado quedan hasta que se ejecuta su línea.

DeclaraciónEstado antes de su líneaÁmbitoRedeclarable
varExiste y vale undefinedFunciónSí (fuente de errores silenciosos)
letExiste pero en zona muerta temporal: usarla lanza ReferenceErrorBloqueNo
constIgual que let, y exige inicializadorBloqueNo
function (declaración)Ya está definida y se puede llamarBloque en modo estricto
classZona muerta temporal, como letBloqueNo
hoisting.ts
console.log(conVar);        // undefined  ← existe, sin valor todavía
// console.log(conLet);     // ReferenceError: Cannot access 'conLet' before initialization
saludar();                  // 'hola'     ← las declaraciones de función sí están listas

var conVar = 1;
let conLet = 2;
function saludar() { return console.log('hola'); }

// Con 'var' obtienes undefined y el fallo aparece tres funciones más allá;
// con 'const' o 'let' falla en el acto, que es justo lo que quieres.
function ejemplo(flag: boolean) {
  if (flag) {
    var visible = 'soy de toda la función';    // ámbito: ejemplo()
    let oculta  = 'solo vivo en este bloque';  // ámbito: el if
  }
  console.log(visible);     // funciona (undefined si flag era false)
  // console.log(oculta);   // ReferenceError: oculta is not defined
}

Regla práctica: const por defecto, let cuando de verdad haya reasignación y var nunca. No es estilo: el ámbito de bloque evita la clase entera de errores del bucle con var, y la zona muerta temporal convierte un error silencioso en una excepción inmediata.

1.8 Asincronía: callbacks, promesas y async/await

1.8.1 Anatomía de una promesa

Una promesa es un objeto con tres estados posibles: pendiente, cumplida (fulfilled) o rechazada (rejected). Una vez resuelta, es inmutable. then, catch y finally devuelven nuevas promesas, lo que permite encadenar.

   new Promise(executor)
          │
     ┌────┴────┐
  resolve   reject
     │         │
     ▼         ▼
  FULFILLED  REJECTED        (estados finales, no cambian nunca más)
     │         │
     └────┬────┘
          ▼
    .then(ok, err) / .catch(err) / .finally(fn)   → devuelve una NUEVA promesa
    Las continuaciones se ejecutan como MICROTAREAS.

1.8.2 Los cuatro combinadores y cuándo usar cada uno

CombinadorSe resuelve cuando…Se rechaza cuando…Caso de uso
Promise.allTodas cumplen (devuelve array de valores)La primera falla (fail-fast)Cargar datos obligatorios en paralelo
Promise.allSettledTodas terminan, con éxito o errorNuncaNotificar a 5 servicios y saber cuáles fallaron
Promise.raceLa primera que termina (cumpla o falle)Si la primera en terminar fallaTimeouts
Promise.anyLa primera que cumpleSolo si todas fallan (AggregateError)Varios espejos o réplicas
dashboard.service.tsINCORRECTO
// Serie innecesaria: 3 peticiones independientes
// tardan la SUMA de sus tiempos (300 + 250 + 400 = 950 ms).
async cargarPanel(userId: number) {
  const usuario  = await this.api.usuario(userId);
  const tareas   = await this.api.tareas(userId);
  const notifs   = await this.api.notificaciones(userId);
  return { usuario, tareas, notifs };
}
dashboard.service.tsCORRECTO
// Paralelo: tarda lo que la MÁS LENTA (400 ms).
async cargarPanel(userId: number) {
  const [usuario, tareas, notifs] = await Promise.all([
    this.api.usuario(userId),
    this.api.tareas(userId),
    this.api.notificaciones(userId),
  ]);
  return { usuario, tareas, notifs };
}

// Si las notificaciones son opcionales, no dejes que tumben el panel:
const [usuario, tareas, notifs] = await Promise.all([
  this.api.usuario(userId),
  this.api.tareas(userId),
  this.api.notificaciones(userId).catch(() => []),
]);

1.8.3 Recorrer colecciones: bucle, Promise.all y concurrencia limitada

Procesar una lista de forma asíncrona parece trivial y es donde más código de producción se rompe. Hay tres estrategias, y elegir mal cuesta o tiempo de respuesta o estabilidad del sistema.

EstrategiaDuraciónCarga sobre el servicio destinoCuándo es la correcta
for...of con awaitSuma de todasUna petición a la vezHay dependencia entre iteraciones, el orden importa o el destino es frágil
Promise.all(map)La más lentaTodas a la vezPocos elementos, conocidos y acotados (tres o cuatro llamadas de un panel)
Concurrencia limitadaTotal dividido entre NComo mucho N a la vezListas grandes o de tamaño desconocido: el caso habitual en un backend
import.service.tsINCORRECTO
// 1) Serie innecesaria: 5.000 filas × 40 ms = 200 segundos.
for (const fila of filas) {
  await this.api.enviar(fila);
}

// 2) El extremo opuesto, igual de malo: 5.000 peticiones
//    simultáneas. Agota el pool de conexiones, provoca
//    ECONNRESET, dispara el rate limit del proveedor y
//    puede tumbar el servicio de destino.
await Promise.all(filas.map((f) => this.api.enviar(f)));

// 3) Y este ni siquiera espera: forEach ignora la promesa.
filas.forEach(async (f) => { await this.api.enviar(f); });
import.service.tsCORRECTO
// N trabajadores consumen un índice compartido: nunca hay más de
// N peticiones vivas y el orden de los resultados se conserva.
async function mapaConcurrente<T, R>(
  items: readonly T[], limite: number, fn: (t: T) => Promise<R>,
): Promise<R[]> {
  const resultados = new Array<R>(items.length);
  let siguiente = 0;
  const trabajador = async () => {
    while (siguiente < items.length) {
      const i = siguiente++;          // reserva: un solo hilo, sin carreras
      resultados[i] = await fn(items[i]!);
    }
  };
  const n = Math.min(limite, items.length);
  await Promise.all(Array.from({ length: n }, trabajador));
  return resultados;
}

const respuestas = await mapaConcurrente(filas, 8, (f) => this.api.enviar(f));
Por qué el reparto por índice y no por trozos Partir la lista en bloques de N y hacer un Promise.all por bloque también funciona, pero desperdicia tiempo: cada bloque tarda lo que su elemento más lento. Con trabajadores, en cuanto uno acaba coge el siguiente elemento disponible y la ocupación se mantiene al máximo, igual que en un pool de conexiones.

1.8.4 Errores asíncronos: los tres fallos clásicos

errores.tsINCORRECTO
// FALLO 1: try/catch que no captura nada.
// La promesa se rechaza DESPUÉS de que el try haya terminado.
try {
  hacerAlgoAsync();            // falta await
} catch (e) {
  console.error('nunca llega aquí');
}

// FALLO 2: forEach no espera. La función termina antes que las promesas.
items.forEach(async (item) => { await guardar(item); });
console.log('¿guardado?');     // se imprime ANTES de guardar nada

// FALLO 3: swallow. Se traga el error y se sigue como si nada,
// devolviendo undefined y provocando un fallo lejos del origen.
async function leer() {
  try { return await api.get(); } catch { /* silencio */ }
}
errores.tsCORRECTO
// 1: await dentro del try
try {
  await hacerAlgoAsync();
} catch (e) {
  logger.error({ err: e }, 'fallo al hacer algo');
  throw e;                        // relanza si no puedes recuperarte aquí
}

// 2a: en serie y en orden (si importa el orden o hay límite de concurrencia)
for (const item of items) {
  await guardar(item);
}

// 2b: en paralelo cuando son independientes
await Promise.all(items.map((item) => guardar(item)));

// 3: o manejas el error de verdad, o lo dejas subir con contexto
async function leer(): Promise<Datos> {
  try {
    return await api.get();
  } catch (causa) {
    // 'cause' preserva el error original para el diagnóstico
    throw new Error('No se pudieron leer los datos del catálogo', { cause: causa });
  }
}
Errores tipados en TypeScript En un catch (e), e es de tipo unknown (con useUnknownInCatchVariables, activo con strict). No puedes hacer e.message directamente: hay que estrechar el tipo con e instanceof Error. Es incómodo, pero evita suposiciones falsas: en JavaScript se puede lanzar cualquier cosa, incluida una cadena.

1.8.5 Promesas flotantes y estilo de encadenamiento

Una promesa flotante (floating promise) es una promesa que nadie espera ni observa. El código sigue adelante, y si la promesa se rechaza obtienes un UnhandledPromiseRejection que en Node moderno termina el proceso por defecto. Es una de las pocas formas de tumbar un servidor entero desde una línea que parecía inofensiva.

tasks.service.tsINCORRECTO
async crear(dto: CreateTaskDto) {
  const tarea = await this.repo.crear(dto);

  // Promesa flotante: si el correo falla, nadie lo captura
  // y el proceso puede caer varios milisegundos después,
  // en mitad de OTRA petición.
  this.mailer.enviarAviso(tarea);

  return tarea;
}
tasks.service.tsCORRECTO
async crear(dto: CreateTaskDto) {
  const tarea = await this.repo.crear(dto);

  // Opción A: es parte de la operación → espérala.
  await this.mailer.enviarAviso(tarea);

  // Opción B: es accesoria → decláralo explícitamente
  // y captura SIEMPRE el error (o encólala, capítulo 11).
  void this.mailer.enviarAviso(tarea)
    .catch((err) => this.logger.error({ err }, 'aviso no enviado'));

  return tarea;
}

La regla ESLint @typescript-eslint/no-floating-promises detecta este fallo en tiempo de compilación y debería estar activada como error en cualquier proyecto Node serio. El operador void delante de una llamada es la forma convencional de decirle al equipo y al linter «sé lo que hago, esta promesa es deliberadamente asíncrona».

La otra decisión de estilo es encadenamiento frente a async/await. Son equivalentes en potencia, no en legibilidad:

SituaciónEstilo recomendadoMotivo
Secuencia de pasos con variables intermediasasync/awaitSe lee como código síncrono; el try/catch y el stack trace funcionan de forma natural
Una sola transformación del resultado.then(fn)Evita una función async entera para una línea
Ejecución en paraleloCombinadores + awaitawait Promise.all([...]) es más claro que anidar then
Fallback puntual.catch(() => valorPorDefecto)Más conciso que un try/catch de cinco líneas
Bucles y condicionalesasync/awaitEncadenar promesas dentro de un bucle es casi siempre ilegible

1.8.6 Cancelación con AbortController y timeouts

Una promesa no se puede cancelar: una vez creada, el trabajo ya está en marcha y solo puedes decidir si te sigue interesando el resultado. Para cancelar de verdad hace falta que la operación subyacente colabore, y el mecanismo estándar para eso es AbortController: un objeto que emite una señal (AbortSignal) que las APIs modernas (fetch, los streams, muchos clientes HTTP y de base de datos) saben escuchar.

cancelacion.ts
// 1) Cancelación manual: el usuario cambia de página y la petición sobra
const controlador = new AbortController();
const promesa = fetch('/api/tasks', { signal: controlador.signal });
controlador.abort();               // la promesa se rechaza con AbortError

// 2) Timeout declarativo, y 3) combinación de señales (aborta el usuario O vence el plazo)
await fetch('/api/informe', { signal: AbortSignal.timeout(5_000) });
const senal = AbortSignal.any([controlador.signal, AbortSignal.timeout(5_000)]);

// 4) Timeout genérico para cualquier promesa, con limpieza correcta.
//    OJO: la promesa perdedora NO se cancela, solo se ignora su resultado.
function conTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
  let temporizador: NodeJS.Timeout;
  const limite = new Promise<never>((_, rechazar) => {
    temporizador = setTimeout(() => rechazar(new Error(`Timeout de ${ms} ms`)), ms);
  });
  // finally: sin esto, el timer mantiene vivo el proceso hasta que vence
  return Promise.race([p, limite]).finally(() => clearTimeout(temporizador));
}

// 5) Hacer cancelable una función propia: escuchar la señal y limpiar
function esperar(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise((resolver, rechazar) => {
    if (signal?.aborted) return rechazar(signal.reason);
    const id = setTimeout(resolver, ms);
    signal?.addEventListener('abort', () => {
      clearTimeout(id);
      rechazar(signal.reason);
    }, { once: true });          // once evita acumular listeners
  });
}
Un timeout no libera al servidor de trabajo Si tu cliente abandona a los 5 segundos una consulta SQL que tarda 30, la consulta sigue ejecutándose en la base de datos y sigue consumiendo una conexión del pool. La cancelación real exige que cada capa la propague: señal en el cliente HTTP, statement_timeout en PostgreSQL, cierre de la conexión. Diseñar timeouts sin propagación es cambiar un fallo lento por un fallo lento e invisible.

1.8.7 Reintentos con retroceso exponencial

Reintentar es la respuesta correcta ante un fallo transitorio y una forma excelente de amplificar una avería en cualquier otro caso. Antes de escribir el bucle hay que responder a dos preguntas: ¿es el error reintentable? y ¿es la operación idempotente?

Situación¿Reintentar?Motivo
ECONNRESET, ETIMEDOUT, error de DNSFallo de red típicamente transitorio
HTTP 429 y 503 con cabecera Retry-AfterSí, respetando la cabeceraEl servicio te está diciendo cuándo volver
HTTP 500 en una operación de solo lecturaSí, con límiteIdempotente: repetirla no tiene efectos
HTTP 500 en un POST de cobroSolo con clave de idempotenciaPuede haberse ejecutado antes de fallar: cobrarías dos veces
HTTP 400, 401, 403, 404, 422NoEl error es tuyo: repetirlo dará exactamente lo mismo
Error de validación o de lógica de negocioNoReintentar solo retrasa el diagnóstico

El retroceso exponencial multiplica la espera en cada intento (200 ms, 400 ms, 800 ms…) para dar tiempo al servicio a recuperarse, y el jitter añade una componente aleatoria para que mil clientes que fallaron a la vez no reintenten a la vez. Sin jitter, tus reintentos son un ataque de denegación de servicio contra el sistema que intentas ayudar; el fenómeno se conoce como «manada atronadora» (thundering herd). En los ejercicios de este capítulo lo implementarás desde cero; en producción conviene además limitar el número total de reintentos en vuelo con un cortacircuitos (circuit breaker), que veremos en el capítulo 18.

1.9 Iteradores, generadores y símbolos

1.9.1 El protocolo iterable: qué significa «ser recorrible»

En JavaScript, «recorrible» no es una propiedad mágica de los arrays: es un protocolo, es decir, un acuerdo sobre qué métodos debe tener un objeto. Cualquier valor que implemente ese acuerdo funciona con for...of, con el operador de propagación (...), con la desestructuración, con Array.from y con Promise.all.

  for (const x of coleccion) { ... }
         │
         ▼
  coleccion[Symbol.iterator]()  ─────►  devuelve un ITERADOR
         │
         ▼
  iterador.next()  ─────►  { value: 'a', done: false }
  iterador.next()  ─────►  { value: 'b', done: false }
  iterador.next()  ─────►  { value: undefined, done: true }   ← el bucle termina
         │
         └─ si el bucle se corta con break, return o una excepción:
            iterador.return()  ─────►  única oportunidad de liberar recursos
                                       (cerrar el fichero, el cursor, la conexión)
¿Es iterable?ValoresNota
Array, String, Map, Set, TypedArray, arguments, NodeList, generadoresUn String se recorre por caracteres Unicode completos, no por unidades UTF-16
NoObjetos literales, Object en generalSe recorren con Object.keys/values/entries, que devuelven arrays
iterable-propio.ts
// Implementar el protocolo hace que TU tipo funcione con toda la sintaxis del lenguaje
class RangoFechas implements Iterable<Date> {
  constructor(private readonly desde: Date, private readonly hasta: Date) {}

  *[Symbol.iterator](): Iterator<Date> {     // un generador cumple el protocolo por sí solo
    for (let d = new Date(this.desde); d <= this.hasta; d.setDate(d.getDate() + 1)) {
      yield new Date(d);                     // copia: no cedas el objeto mutable interno
    }
  }
}

const semana = new RangoFechas(new Date('2026-03-01'), new Date('2026-03-07'));

for (const dia of semana) { /* siete fechas */ }
const dias = [...semana];                    // el spread usa el mismo protocolo
const [primero, segundo] = semana;           // la desestructuración, también
console.log(Array.from(semana, (d) => d.toISOString().slice(0, 10)));

1.9.2 Generadores: producir valores bajo demanda

Un generador (function*) es una función que se puede pausar y reanudar. Cada yield devuelve un valor y congela el estado de la función hasta la siguiente llamada a next(). Esto permite describir secuencias potencialmente infinitas, procesar datos sin materializarlos en memoria y escribir iteradores complejos sin gestionar estado a mano. El segundo ejemplo anticipa los iteradores asíncronos, que detallamos justo después.

generadores.ts
// Generador: produce valores bajo demanda (perezoso), sin materializar todo en memoria
function* paginas(total: number, tam: number): Generator<number> {
  for (let offset = 0; offset < total; offset += tam) yield offset;
}
for (const offset of paginas(1000, 100)) { /* 0, 100, 200, ... */ }

// Iterador asíncrono: procesar un dataset enorme sin cargarlo entero.
// Patrón real para exportaciones o migraciones de datos.
async function* leerPorLotes(em: EntityManager, tam = 500) {
  let offset = 0;
  while (true) {
    const lote = await em.find(Task, {}, { limit: tam, offset });
    if (lote.length === 0) return;
    yield lote;
    offset += tam;
    em.clear();          // libera el Identity Map: clave para no agotar la memoria
  }
}

for await (const lote of leerPorLotes(em)) {
  await procesar(lote);
}

Los generadores tienen además una capacidad que se usa poco y conviene conocer: la comunicación en dos sentidos. const x = yield v devuelve v al consumidor y recibe el valor que este pase en el siguiente next(valor). Sobre ese mecanismo se construyeron las primeras implementaciones de async/await (y las corrutinas de redux-saga): un generador que cede promesas y un motor que las resuelve y le devuelve el resultado.

1.9.3 Iteradores asíncronos y for await...of

Un iterador asíncrono es lo mismo con una diferencia: su método next() devuelve una promesa de { value, done } en lugar del objeto directamente, y el protocolo se declara con Symbol.asyncIterator. Es la herramienta correcta para consumir orígenes de datos que llegan por partes: un fichero de varios gigabytes, un cursor de base de datos, una API paginada o un socket.

flujos.tsPROCESAR SIN CARGAR EN MEMORIA
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';

// CASO 1: contar líneas de un CSV de 8 GB con memoria constante. Los streams de
// Node implementan Symbol.asyncIterator, así que se recorren con for await.
async function procesarCsv(ruta: string): Promise<number> {
  const lineas = createInterface({
    input: createReadStream(ruta, { encoding: 'utf8' }),
    crlfDelay: Infinity,               // trata \r\n como un único salto de línea
  });
  let total = 0;
  for await (const linea of lineas) {  // una línea cada vez, nunca el fichero entero
    if (linea.trim() !== '') total++;
  }
  return total;
}

// CASO 2: una API paginada expuesta como secuencia. El consumidor no sabe
// (ni le importa) que por debajo hay paginación.
async function* todosLosClientes(http: HttpService): AsyncGenerator<Cliente> {
  let pagina = 1;
  while (true) {
    const { datos, hayMas } = await http.get<Respuesta>(`/clientes?page=${pagina}`);
    for (const cliente of datos) yield cliente;   // se cede uno a uno
    if (!hayMas) return;
    pagina++;
  }
}

for await (const cliente of todosLosClientes(http)) {
  await sincronizar(cliente);          // se detiene cuando quieras: no hay más peticiones
}
Tres detalles que sorprenden

Uno: for await...of es estrictamente secuencial, que es lo que quieres para no agotar la memoria; si necesitas paralelismo, acumula lotes y aplica concurrencia limitada dentro.

Dos: si sales con break o con una excepción, el motor llama a return() sobre el iterador, lo que ejecuta los bloques finally del generador. Ahí es donde debes cerrar el fichero, el cursor o la transacción.

Tres: array.map(async fn) devuelve un array de promesas, no un iterable asíncrono; lanza todo a la vez y luego espera en orden.

1.9.4 Símbolos: claves que nunca chocan

Un symbol es un valor primitivo único: dos símbolos creados con la misma descripción no son iguales. Sirven para dos cosas muy concretas: añadir propiedades a un objeto ajeno sin riesgo de colisión (y sin que aparezcan en Object.keys ni en JSON.stringify) y participar en los protocolos internos del lenguaje mediante los llamados símbolos bien conocidos.

Símbolo bien conocidoPara qué sirve
Symbol.iteratorHace un objeto recorrible con for...of y con el operador de propagación
Symbol.asyncIteratorLo mismo para for await...of
Symbol.toStringTagPersonaliza el resultado de Object.prototype.toString (útil en depuración)
Symbol.toPrimitiveControla la conversión a número o cadena: evita coerciones sorprendentes en tus tipos
Symbol.hasInstancePersonaliza el comportamiento de instanceof
simbolos.ts
const ID = Symbol('id');
console.log(Symbol('id') === Symbol('id'));   // false: cada símbolo es único
console.log(Symbol.for('app') === Symbol.for('app'));  // true: registro global compartido

const usuario = { nombre: 'Ana', [ID]: 42 };
console.log(Object.keys(usuario));            // ['nombre']  ← el símbolo no aparece
console.log(JSON.stringify(usuario));         // {"nombre":"Ana"}  ← tampoco se serializa
console.log(usuario[ID]);                     // 42, si tienes la referencia al símbolo

// Uso profesional número uno: tokens de inyección de dependencias que no colisionan.
export const PASARELA_PAGO = Symbol('PASARELA_PAGO');   // lo veremos en la sección 1.15

// Uso profesional número dos: metadatos internos en una entidad sin ensuciar su JSON.
const ORIGINAL = Symbol('instantanea');
type ConInstantanea<T> = T & { [ORIGINAL]?: T };

1.10 Estructuras de datos: Map, Set y compañía

1.10.1 Map y Set frente a objeto y array

Usar un objeto literal como diccionario y un array como conjunto es una costumbre heredada de cuando no había alternativa. Desde ES2015 la hay, y la elección tiene consecuencias medibles de corrección y de rendimiento.

CriterioObjectMapArraySet
Tipo de claveCadena o símbolo (todo se convierte a cadena)Cualquier valor, incluidos objetos y funcionesÍndice numéricoCualquier valor
Buscar un elementoO(1)O(1)O(n) con includes/findO(1) con has
Saber cuántos hayObject.keys(o).length, O(n).size, O(1).length, O(1).size, O(1)
Orden al recorrerReglas especiales (ver 1.10.3)Estricto orden de inserciónPosicionalEstricto orden de inserción
Claves heredadasSí: toString, constructor… pueden colisionarNo: no hereda nadaNo
Serializable con JSONSí, directamenteNo: hay que convertir con Object.fromEntriesNo: [...set]
Borrar una entradadelete: degrada la forma oculta.delete(): barato y previstosplice: O(n).delete(): O(1)
deduplicar.tsINCORRECTO
// O(n²): por cada elemento se recorre el array entero.
// Con 20.000 tareas son 400 millones de comparaciones
// y varios segundos de event loop bloqueado.
const vistos: number[] = [];
const unicas: Tarea[] = [];
for (const t of tareas) {
  if (!vistos.includes(t.id)) {
    vistos.push(t.id);
    unicas.push(t);
  }
}

// El diccionario con objeto también tiene trampas:
const contador: Record<string, number> = {};
contador['constructor'] ??= 0;   // hereda una FUNCIÓN del prototipo
contador[1] = 5;                 // la clave se convierte en '1'
deduplicar.tsCORRECTO
// O(n): Set resuelve la pertenencia en tiempo constante.
const vistos = new Set<number>();
const unicas = tareas.filter((t) => {
  if (vistos.has(t.id)) return false;
  vistos.add(t.id);
  return true;
});

// Deduplicar valores primitivos, en una línea:
const etiquetas = [...new Set(tareas.flatMap((t) => t.etiquetas))];

// Map admite claves de cualquier tipo y no hereda nada:
const porProyecto = new Map<Proyecto, Tarea[]>();
for (const t of tareas) {
  const lista = porProyecto.get(t.proyecto) ?? [];
  lista.push(t);
  porProyecto.set(t.proyecto, lista);
}
console.log(porProyecto.size);   // O(1)

1.10.2 WeakMap y WeakSet: metadatos sin retener memoria

Un Map normal mantiene vivas sus claves: mientras el mapa exista, ninguno de los objetos que use como clave podrá ser recolectado. Si lo usas como caché indexada por entidad, has construido una fuga de memoria perfecta. WeakMap resuelve justo eso: sus referencias son débiles, de manera que si nadie más apunta a la clave, el recolector se lleva la clave y la entrada desaparece sola.

  Map (referencia fuerte)                 WeakMap (referencia débil)
  ────────────────────────                ─────────────────────────────
  mapa ──────► [ clave ──► valor ]        mapa ┈┈┈┈► [ clave ──► valor ]
                  ▲                                     ▲
                  │ el mapa CUENTA como usuario         │ el mapa NO cuenta
  componente ─────┘                       componente ───┘

  Si 'componente' desaparece:             Si 'componente' desaparece:
  · el Map lo sigue reteniendo            · nadie lo referencia con fuerza
  · nunca se libera  → FUGA               · el recolector lo libera, y con él
                                            la entrada del WeakMap
AspectoWeakMap / WeakSet
Claves admitidasSolo objetos (y símbolos no registrados); nunca cadenas ni números
Operacionesget, set, has, delete. No hay size ni forma de recorrerlo
Motivo de esa limitaciónEl contenido depende del recolector: exponerlo haría el programa no determinista
Casos de uso realesDatos privados asociados a una instancia, memoización por objeto, marcar objetos ya procesados en un recorrido con ciclos, asociar metadatos a nodos del DOM o a entidades del ORM

1.10.3 El orden de las claves de un objeto no es el que crees

Mucha gente asume que un objeto conserva el orden de inserción. Es cierto solo en parte, y la excepción da lugar a errores difíciles de ver: las claves que parecen enteros positivos se recorren primero y en orden numérico ascendente, después el resto de cadenas en orden de inserción, y por último los símbolos.

orden-claves.ts
const o: Record<string, unknown> = {};
o.zeta = 1;
o['10'] = 2;
o.alfa = 3;
o['2'] = 4;

console.log(Object.keys(o));        // ['2', '10', 'zeta', 'alfa']  ← ¡no es el de inserción!
console.log(JSON.stringify(o));     // {"2":4,"10":2,"zeta":1,"alfa":3}

// Un Map respeta SIEMPRE el orden de inserción, sin excepciones:
const m = new Map<string, unknown>([['zeta', 1], ['10', 2], ['alfa', 3], ['2', 4]]);
console.log([...m.keys()]);         // ['zeta', '10', 'alfa', '2']

// Consecuencia práctica: JSON.stringify NO es una clave de caché fiable,
// porque el orden de las propiedades cambia el resultado.
JSON.stringify({ a: 1, b: 2 }) === JSON.stringify({ b: 2, a: 1 });   // false

// Si necesitas una clave estable, ordena las propiedades explícitamente:
const clave = (o: object) =>
  JSON.stringify(Object.entries(o).sort(([a], [b]) => a.localeCompare(b)));
Dónde muerde esto en el stack En la memoización por JSON.stringify(args) del apartado de closures (dos objetos equivalentes producen claves distintas y la caché nunca acierta), en los mapas de traducciones o de columnas indexados por identificador numérico (el orden que ves en la interfaz no es el que insertaste) y en las respuestas de una API que un cliente compara literalmente. Cuando el orden importe, usa un Map, un array de pares o un campo de ordenación explícito.

1.11 Inmutabilidad y copias

1.11.1 Copia superficial frente a copia profunda

Ya vimos que { ...obj } crea un objeto nuevo pero comparte los objetos anidados. Esa es la diferencia entre copia superficial y profunda, y elegir mal produce el error más difícil de depurar que existe: modificar algo «que no habías tocado».

TécnicaProfundidadConserva…Problemas
{ ...obj } / Object.assignSuperficialValores propios enumerablesComparte los anidados; pierde el prototipo y los getters (los evalúa)
[...arr] / arr.slice()SuperficialOrden y elementosLos elementos siguen siendo las mismas referencias
structuredClone(obj)ProfundaDate, Map, Set, RegExp, ArrayBuffer, ciclosNo copia funciones, Symbol, prototipos de clase ni descriptores; lanza DataCloneError
JSON.parse(JSON.stringify(obj))Profunda «de mentira»Solo lo que sobrevive a JSONVer la lista de abajo: es una mala idea casi siempre
Constructor de copia propioLa que decidasTodo lo que programesHay que mantenerlo; es la opción correcta para entidades
Por qué JSON.parse(JSON.stringify(x)) es una mala idea

Es el truco más repetido de internet y falla en silencio: convierte los Date en cadenas (y ya no vuelven), elimina las propiedades cuyo valor es undefined, las funciones y los símbolos, transforma Map, Set y las instancias de clase en objetos planos (pierdes los métodos y el prototipo), convierte NaN e Infinity en null, lanza una excepción con referencias circulares y con bigint, y ejecuta cualquier toJSON que encuentre por el camino. Además es lento: serializa a texto y vuelve a analizarlo.

Si necesitas una copia profunda de datos planos, usa structuredClone (Node 17+ y todos los navegadores modernos). Si trabajas con entidades del ORM, no clones: crea un DTO explícito.

1.11.2 El coste real de la inmutabilidad

La inmutabilidad es una decisión de diseño excelente (permite comparar por referencia, elimina los efectos a distancia y es lo que hacen posibles OnPush y las señales de Angular), pero no es gratis. El error típico no es copiar, sino copiar dentro de un bucle: convierte un algoritmo lineal en uno cuadrático.

acumular.tsINCORRECTO
// O(n²): en cada vuelta se copia TODO el acumulado.
// Con 50.000 elementos son 1.250 millones de copias.
let lista: Tarea[] = [];
for (const t of nuevas) {
  lista = [...lista, t];
}

let indice: Record<number, Tarea> = {};
for (const t of nuevas) {
  indice = { ...indice, [t.id]: t };   // idéntico problema
}
acumular.tsCORRECTO
// Muta una estructura LOCAL y publica una sola referencia nueva.
// La inmutabilidad hacia fuera no obliga a serlo por dentro.
const acumulador: Tarea[] = [];
for (const t of nuevas) acumulador.push(t);
this.lista.set(acumulador);            // una única referencia nueva

// O directamente, sin bucle:
this.lista.update((prev) => [...prev, ...nuevas]);   // UNA copia, no n

const indice = new Map(nuevas.map((t) => [t.id, t]));

Como regla de calibración: copiar un array de 10.000 objetos con el operador de propagación cuesta del orden de decenas de microsegundos, algo irrelevante si ocurre una vez por interacción del usuario, e inaceptable si ocurre 10.000 veces. La pregunta correcta nunca es «¿copio o muto?», sino «¿cuántas veces copio y qué tamaño tiene lo copiado?». Para el estado de una aplicación normal, la copia inmutable en el límite (una nueva referencia por evento) es la respuesta adecuada; para el bucle interno de un algoritmo, la mutación local es correcta y no compromete nada, porque la estructura mutable nunca escapa de la función.

Object.freeze y readonly: dos garantías distintas readonly de TypeScript es una comprobación de compilación que desaparece al ejecutar: nadie impide que una librería en JavaScript modifique el objeto. Object.freeze sí actúa en tiempo de ejecución, pero es superficial (los objetos anidados siguen siendo mutables), en modo estricto lanza TypeError al intentar escribir y tiene un coste al recorrer estructuras grandes. Recomendación: readonly por defecto en todo el código, y Object.freeze reservado para constantes de configuración compartidas y para el modo de desarrollo, donde convierte un error silencioso en una excepción inmediata.

1.12 Módulos: CommonJS, ESM y tree shaking

1.12.1 Los dos sistemas y por qué conviven

JavaScript tardó veinte años en tener un sistema de módulos oficial. Node no podía esperar y adoptó CommonJS en 2009; el estándar (ESM) llegó con ES2015 y Node lo soporta de forma nativa desde la versión 12. El resultado es que hoy conviven los dos, y buena parte de los errores de configuración de un proyecto salen de esa convivencia.

AspectoCommonJS (CJS)ES Modules (ESM)
Sintaxisrequire() / module.exportsimport / export
CargaSíncrona, en tiempo de ejecuciónEstática, analizable antes de ejecutar
Tree shakingNo (las exportaciones son dinámicas)Sí: el bundler elimina lo no usado
Dónde dominaNode clásico, muchas librerías de NestJSNavegador, Angular, Node moderno
Importación dinámicarequire() en cualquier puntoimport() devuelve una promesa
modulos.ts
// Importación estática: se resuelve en tiempo de compilación
import { HttpClient } from '@angular/common/http';

// Importación dinámica: devuelve una promesa y crea un "chunk" separado.
// Es la base del lazy loading de rutas en Angular (capítulo 6).
const ruta = {
  path: 'admin',
  loadComponent: () => import('./admin/admin.component').then((m) => m.AdminComponent),
};

// import type: se borra por completo al compilar (no genera require/import en runtime).
// Evita dependencias circulares y ciclos de carga en Nest y MikroORM.
import type { Task } from './task.entity';
Error frecuente: import type y decoradores Si importas una entidad solo con import type pero la usas en un decorador (@ManyToOne(() => Project)), la referencia desaparece en runtime y el ORM no encuentra la clase. Con relaciones, usa importación normal y funciones flecha perezosas en el decorador; así rompes el ciclo sin perder la referencia.

1.12.2 Resolución de módulos y rutas

Cuando escribes import { x } from 'algo', alguien tiene que decidir qué fichero es «algo». El algoritmo de Node es determinista y merece la pena conocerlo, porque el 90 % de los errores Cannot find module se explican con él.

Forma del especificadorCómo se resuelve
Relativo: ./tasks.serviceDesde el directorio del fichero actual. En ESM la extensión es obligatoria (./tasks.service.js); en CommonJS se prueban .js, .json, .node y el index del directorio
Absoluto: /opt/app/x.jsRuta del sistema de ficheros tal cual. Casi nunca se usa en código de aplicación
Desnudo: @nestjs/commonBusca node_modules/@nestjs/common en el directorio actual y, si no lo encuentra, va subiendo carpeta a carpeta hasta la raíz del disco
Interno: #configMapa imports del package.json: alias privados del propio paquete, resueltos por Node sin necesidad de empaquetador

Dentro de un paquete, el campo exports del package.json es el que manda: define qué rutas son públicas y qué fichero sirve para cada condición (import para ESM, require para CommonJS, types para TypeScript). Si un paquete declara exports, importar un fichero interno suyo (libreria/dist/util.js) falla aunque el fichero exista: es una restricción deliberada para que los autores puedan reorganizar su código sin romper a nadie.

Tres trampas de resolución que te vas a encontrar

1. Los paths de tsconfig.json no existen en tiempo de ejecución. El alias @app/* lo entiende el compilador para comprobar tipos, pero tsc lo deja tal cual en el JavaScript generado. Si ejecutas ese código con node sin un empaquetador ni tsconfig-paths (o sin los imports del package.json), obtendrás Cannot find module '@app/...' solo en producción.

2. __dirname y require no existen en ESM. Los equivalentes son import.meta.url con fileURLToPath, y createRequire cuando de verdad necesites cargar un módulo CommonJS. Afecta a cualquier código que lea plantillas o migraciones por ruta.

3. Los sistemas de ficheros no son iguales. macOS y Windows no distinguen mayúsculas y Linux sí: import './TasksService' puede funcionar en tu portátil y fallar en el contenedor de producción. La opción forceConsistentCasingInFileNames existe justo para esto.

1.13 Node.js: lo que hay que saber para el backend

1.13.1 Arquitectura

  ┌──────────────────────────────────────────────┐
  │  Tu código (NestJS, MikroORM, Express...)    │
  ├──────────────────────────────────────────────┤
  │  API de Node: fs, http, crypto, streams...   │  JavaScript
  ├──────────────────────────────────────────────┤
  │  Bindings C++                                │
  ├───────────────────────┬──────────────────────┤
  │        V8             │        libuv         │  C/C++
  │  (ejecuta JS)         │  (event loop, pool,  │
  │                       │   E/S no bloqueante) │
  └───────────────────────┴──────────────────────┘
                       Sistema operativo

1.13.2 Piezas que usarás en el stack

1.13.3 Streams y contrapresión

Un stream es una abstracción para trabajar con datos que no caben (o no compensa que quepan) en memoria: se procesan por trozos según van llegando. Node define cuatro tipos: Readable (origen, como un fichero o una respuesta HTTP), Writable (destino, como la respuesta que envías al cliente), Duplex (ambos, como un socket) y Transform (un duplex que modifica lo que pasa por él, como la compresión gzip).

El concepto que de verdad hay que entender es la contrapresión (backpressure): qué ocurre cuando el origen produce más deprisa de lo que el destino consume.

  ORIGEN RÁPIDO                              DESTINO LENTO
  (fichero en disco SSD, ~200 MB/s)          (red del cliente, ~5 MB/s)

  ┌──────────┐   push    ┌──────────────────┐   write   ┌──────────┐
  │ Readable │ ────────► │  búfer interno   │ ────────► │ Writable │
  └──────────┘           │  (highWaterMark) │           └──────────┘
        ▲                └────────┬─────────┘
        │                         │ si el búfer se llena,
        │  pause()                │ write() devuelve false
        └─────────────────────────┘
                       ▲
                       └── CONTRAPRESIÓN: el origen deja de leer hasta que el
                           destino emite 'drain'. Sin ella, el búfer crece sin
                           límite: la memoria del proceso sube hasta el OOM.

  SIN streams: readFile carga 8 GB en RAM  →  el proceso muere con 20 usuarios.
  CON streams: memoria constante (~64 KB por conexión)  →  escala a miles.
export.controller.tsINCORRECTO
// Carga el fichero entero en memoria y además
// lo duplica al convertirlo a cadena. Con varios
// usuarios simultáneos, el proceso se queda sin RAM.
@Get('export')
async exportar(@Res() res: Response) {
  const datos = await readFile('/tmp/informe.csv', 'utf8');
  res.send(datos);
}

// Variante igual de mala: escribir sin comprobar el
// retorno de write() ignora la contrapresión.
for (const fila of millonesDeFilas) {
  res.write(serializar(fila));   // el búfer crece sin freno
}
export.controller.tsCORRECTO
import { pipeline } from 'node:stream/promises';
import { createReadStream } from 'node:fs';
import { createGzip } from 'node:zlib';

// pipeline conecta los tres extremos, propaga la
// contrapresión, gestiona los errores y destruye
// todos los streams si alguno falla.
@Get('export')
async exportar(@Res() res: Response) {
  res.setHeader('Content-Type', 'application/gzip');
  await pipeline(
    createReadStream('/tmp/informe.csv'),
    createGzip(),
    res,
  );
}

// Para generar datos propios, un generador asíncrono
// vale como origen: Readable.from lo convierte en stream.
await pipeline(
  Readable.from(generarFilasCsv(em)),   // async generator
  createWriteStream('/tmp/salida.csv'),
);
Usa siempre pipeline, nunca .pipe() encadenado El método pipe() clásico no propaga los errores: si el destino falla, el origen se queda abierto y tienes una fuga de descriptores de fichero que solo se manifiesta bajo carga. pipeline() (y su versión con promesas en node:stream/promises) cierra y destruye toda la cadena ante cualquier fallo. Es una de esas reglas que no admiten excepción.

1.13.4 Buffer y datos binarios

Un Buffer es una porción de memoria fuera del montículo de V8 que representa bytes en crudo. Es una subclase de Uint8Array, así que todo lo que sirve para arrays tipados sirve para él. Aparece siempre que hay ficheros, criptografía, imágenes o protocolos binarios.

buffers.ts
const b = Buffer.from('Año 2026', 'utf8');
console.log(b.length);                       // 9 bytes: la 'ñ' ocupa DOS
console.log('Año 2026'.length);              // 8 caracteres
console.log(Buffer.byteLength('Año 2026'));  // 9  ← lo que hay que validar contra un límite

console.log(b.toString('base64'));           // QcOxbyAyMDI2
console.log(b.subarray(0, 3).toString());    // 'Añ'  ← vista SIN copiar memoria

// Errores típicos
Buffer.allocUnsafe(1024);   // rápido pero puede contener datos de otras peticiones: rellénalo siempre
Buffer.alloc(1024);         // inicializado a cero: la opción segura por defecto
Buffer.concat([b1, b2]);    // concatenar así, no con '+' (que convertiría a cadena y corrompería)

// En código compartido con el navegador, usa la API estándar:
const bytes = new TextEncoder().encode('hola');      // Uint8Array
const texto = new TextDecoder('utf8').decode(bytes); // 'hola'
Validar tamaños con length es un fallo de seguridad Si limitas un campo a 500 «caracteres» con texto.length, un usuario con emojis o alfabetos no latinos puede enviarte cuatro veces más bytes de los previstos y desbordar la columna de la base de datos o el límite del cuerpo de la petición. Valida con Buffer.byteLength cuando el límite sea de almacenamiento o de transporte, y con la longitud lógica cuando sea de presentación.

1.13.5 process, entorno y apagado ordenado

El objeto global process es la ventana al sistema operativo. Estas son las partes que se usan en un servicio real:

ElementoPara quéAviso
process.envConfiguración por entornoTodo son cadenas: process.env.PORT nunca es un número. Léelo y valídalo una sola vez al arrancar
process.argvArgumentos de la línea de órdenesLos dos primeros son el binario y el script; usa node:util parseArgs para lo demás
process.exit(code)Terminar de inmediatoCorta la E/S pendiente: no lo llames sin haber cerrado antes lo que tengas abierto
process.on('SIGTERM')Señal de apagado del orquestadorEs la base del despliegue sin cortes
process.on('unhandledRejection')Última red de seguridadRegístralo para registrar el error y terminar de forma controlada, no para ignorarlo
process.memoryUsage()DiagnósticoheapUsed y rss son las dos cifras que hay que vigilar
main.ts · apagado ordenadoIMPRESCINDIBLE EN PRODUCCIÓN
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();          // Nest llama a onModuleDestroy/onApplicationShutdown
await app.listen(puerto);

let cerrando = false;
for (const senal of ['SIGTERM', 'SIGINT'] as const) {
  process.on(senal, async () => {
    if (cerrando) return;           // una segunda señal no debe reentrar
    cerrando = true;
    logger.warn({ senal }, 'iniciando apagado ordenado');

    // 1) dejar de aceptar peticiones nuevas   2) terminar las que están en curso
    // 3) cerrar el pool de la base de datos   4) vaciar los logs pendientes
    const limite = setTimeout(() => process.exit(1), 10_000).unref();
    await app.close();
    clearTimeout(limite);
    process.exit(0);
  });
}

Al desplegar una versión nueva, el orquestador envía SIGTERM y espera un plazo de gracia antes de mandar SIGKILL. Si el proceso no escucha la señal, muere de golpe: las peticiones en vuelo se cortan, las transacciones abiertas quedan a merced del timeout de la base de datos y los mensajes en curso se pierden. Estas veinte líneas son la diferencia entre un despliegue invisible y una incidencia (capítulo 21).

1.13.6 AsyncLocalStorage: contexto por petición

contexto.ts · AsyncLocalStorage, la magia detrás de RequestContext
import { AsyncLocalStorage } from 'node:async_hooks';

interface Contexto { requestId: string; userId?: number }
const als = new AsyncLocalStorage<Contexto>();

// Middleware: abre un "ámbito" que acompaña a toda la cadena asíncrona
export function contextoMiddleware(req, res, next) {
  als.run({ requestId: crypto.randomUUID() }, () => next());
}

// En cualquier punto profundo del código, sin pasar parámetros:
export function log(mensaje: string) {
  const ctx = als.getStore();
  console.log(JSON.stringify({ requestId: ctx?.requestId, mensaje }));
}

Lo interesante es cómo funciona: Node mantiene un identificador de contexto asíncrono que se propaga automáticamente a través de await, setTimeout, callbacks de E/S y Promise.all. Es, en la práctica, una variable global por «hilo lógico de ejecución» en un entorno que solo tiene un hilo real. Dos avisos: tiene un coste medible (aunque pequeño desde Node 16, que reescribió su implementación) y se pierde si un callback se registró fuera del contexto, por ejemplo en un emisor de eventos creado al arrancar la aplicación.

1.13.7 worker_threads, cluster y procesos hijo

Un proceso de Node usa un solo núcleo para tu código JavaScript. Para aprovechar una máquina de ocho núcleos, o para sacar el trabajo pesado del hilo que atiende peticiones, hay cuatro caminos y solo uno es el correcto en cada situación.

  CLUSTER (varios procesos)                WORKER_THREADS (varios hilos)
  ─────────────────────────────            ──────────────────────────────────
  ┌── proceso maestro ──┐                  ┌── un solo proceso ──────────────┐
  │  reparte conexiones │                  │  hilo principal (event loop)    │
  ├── proceso hijo (V8) │                  │    ├── worker (V8 propio)       │
  ├── proceso hijo (V8) │                  │    └── worker (V8 propio)       │
  └── proceso hijo (V8) │                  └─────────────────────────────────┘
  memoria: separada por completo           memoria: separada, pero se puede compartir
  arranque: ~50 ms y ~40 MB por proceso      con SharedArrayBuffer (sin copias)
  si uno cae: los demás siguen             arranque: ~10 ms, más barato
  uso: escalar peticiones HTTP             uso: CPU intensiva dentro de un proceso
HerramientaQué esÚsala paraNo la uses para
clusterVarios procesos que comparten el puerto de escuchaAprovechar todos los núcleos con un servidor HTTPNada, si ya despliegas varias réplicas: duplicaría el mecanismo
worker_threadsHilos en el mismo proceso, cada uno con su V8Cálculo intensivo: imágenes, criptografía, ficheros grandesE/S: no aporta nada, ya es no bloqueante
child_processLanzar otro programa (spawn) u otro script (fork)Herramientas externas: ffmpeg, pg_dumpTrabajo pequeño y frecuente: domina el coste de arrancar
Cola de trabajosUn servicio aparte que consume tareas (capítulo 11)Todo lo que pueda esperar: correos, informes, sincronizacionesLo que el usuario está mirando en pantalla

Criterio práctico: antes de complicar la arquitectura, comprueba que el trabajo pesado tiene que estar en Node. Ordenar, agrupar o contar cientos de miles de filas se hace mejor en SQL (capítulo 19), y un informe pesado se hace mejor en una cola. Los worker_threads son la respuesta correcta cuando el cálculo es de CPU, ocurre dentro de la petición y no se puede delegar.

1.13.8 Diagnóstico de fugas de memoria

Una fuga en Node se manifiesta como una curva de memoria que sube escalón a escalón y no baja tras las recolecciones, hasta que el proceso muere con JavaScript heap out of memory o lo mata el orquestador. El diagnóstico sigue siempre el mismo método: confirmar que hay fuga, capturar el montículo y comparar.

diagnostico.sh
# 1. Confirmar el crecimiento: heapUsed sube y no vuelve a bajar tras cada GC
node --expose-gc app.js       # permite forzar global.gc() en pruebas

# 2. Abrir el inspector y capturar instantáneas del montículo desde Chrome DevTools
node --inspect=0.0.0.0:9229 dist/main.js
#    chrome://inspect  →  Memory  →  Heap snapshot

# 3. En un contenedor, capturar sin adjuntar el depurador:
node --heapsnapshot-signal=SIGUSR2 dist/main.js
kill -USR2 <pid>              # escribe un .heapsnapshot en el directorio de trabajo

# 4. Limitar el montículo para reproducir antes el fallo en local
node --max-old-space-size=256 dist/main.js

La técnica que de verdad encuentra la fuga es la de las tres instantáneas: una con el proceso en reposo, otra tras repetir varias veces la operación sospechosa y una tercera tras repetirla de nuevo. Comparando la segunda con la tercera aparecen los objetos que deberían haber muerto, y el panel Retainers muestra la cadena de referencias que los mantiene vivos.

Causa habitualSíntomaSolución
Caché en un Map sin expiraciónUn Map enorme retenido desde un móduloLímite de tamaño y expiración (LRU), o caché externa
Suscripciones y listeners no liberadosMiles de closures reteniendo componentestakeUntilDestroyed, removeListener, { once: true }
Acumular resultados «para el informe»Un array que crece de forma monótonaProcesar por lotes con streams o generadores asíncronos
Identity Map del ORM en un proceso largoMiles de entidades vivas fuera de una peticiónem.clear() o un contexto nuevo por lote (capítulo 14)
Closures que capturan de másContextos con objetos enormes retenidosExtraer solo los datos necesarios antes de crear el closure

1.14 TypeScript: el sistema de tipos como herramienta de diseño

1.14.1 Qué es y qué no es

TypeScript es un superconjunto de JavaScript con tipado estático estructural que se borra al compilar. Desglosemos cada parte porque cada una tiene consecuencias:

estructural.ts
interface Punto { x: number; y: number }

class Vector { constructor(public x: number, public y: number) {} }

// Vector no "implementa" Punto explícitamente, pero tiene su forma:
const p: Punto = new Vector(1, 2);   // ✔ válido: tipado estructural

// El borrado de tipos en acción:
//   TypeScript:  const p: Punto = new Vector(1, 2);
//   JavaScript:  const p = new Vector(1, 2);       ← 'Punto' desapareció

1.14.2 any, unknown y never

TipoSignificadoPuedes…Cuándo usarlo
anyDesactiva el chequeoTodo (y romperlo todo)Casi nunca. Solo en migraciones temporales
unknown«Algo, pero no sé qué»Nada hasta estrecharloEntrada externa: JSON, catch, librerías sin tipos
neverNo existe ningún valorNadaFunciones que siempre lanzan; comprobación de exhaustividad
voidSin valor de retorno útilIgnorar el retornoFunciones de efecto secundario
exhaustividad.tsPATRÓN PROFESIONAL
type Estado = 'pendiente' | 'en_curso' | 'hecha' | 'cancelada';

function etiqueta(estado: Estado): string {
  switch (estado) {
    case 'pendiente': return 'Pendiente';
    case 'en_curso':  return 'En curso';
    case 'hecha':     return 'Completada';
    case 'cancelada': return 'Cancelada';
    default:
      // Si mañana alguien añade 'archivada' al tipo Estado y olvida el case,
      // 'estado' dejará de ser 'never' y ESTO NO COMPILARÁ.
      // El compilador se convierte en tu test de regresión.
      return asegurarInalcanzable(estado);
  }
}

function asegurarInalcanzable(x: never): never {
  throw new Error(`Caso no contemplado: ${JSON.stringify(x)}`);
}

1.14.3 Narrowing, uniones discriminadas y type guards

narrowing.ts
// 1) typeof para primitivos
function longitud(x: string | number) {
  return typeof x === 'string' ? x.length : String(x).length;
}

// 2) in para distinguir formas de objeto
interface Perro { ladrar(): void }
interface Gato  { maullar(): void }
function hablar(a: Perro | Gato) {
  if ('ladrar' in a) a.ladrar(); else a.maullar();
}

// 3) instanceof para clases
if (error instanceof HttpException) { /* ... */ }

// 4) Uniones discriminadas: la técnica más robusta.
//    Una propiedad literal común ('tipo') identifica cada variante.
type Resultado<T> =
  | { tipo: 'ok'; datos: T }
  | { tipo: 'error'; mensaje: string; codigo: number };

function manejar<T>(r: Resultado<T>) {
  if (r.tipo === 'ok') {
    return r.datos;        // TS sabe que aquí existe 'datos' y no 'mensaje'
  }
  throw new Error(`[${r.codigo}] ${r.mensaje}`);
}

// 5) Type guard personalizado: la firma "x is T" enseña al compilador
function esTask(x: unknown): x is Task {
  return typeof x === 'object' && x !== null && 'title' in x && 'status' in x;
}

// 6) Función de aserción: estrecha el tipo a partir de este punto
function afirmarDefinido<T>(v: T, msg = 'valor requerido'): asserts v is NonNullable<T> {
  if (v === null || v === undefined) throw new Error(msg);
}

1.14.4 Genéricos

Un genérico es un parámetro de tipo: permite escribir código reutilizable sin perder información de tipos (que es justo lo que se pierde con any).

repo.tsINCORRECTO
// Con any, el llamador pierde toda la ayuda del compilador
function primero(items: any[]): any {
  return items[0];
}
const t = primero(tareas);
t.titulo;      // no existe (es 'title') y NADIE avisa
repo.tsCORRECTO
// El tipo entra y sale: se conserva la información
function primero<T>(items: readonly T[]): T | undefined {
  return items[0];
}
const t = primero(tareas);   // T se infiere como Task
t?.titulo;   // Error de compilación: Property 'titulo' does not exist on 'Task'
genericos-avanzados.ts
// Restricción: T debe tener al menos una propiedad id
function indexar<T extends { id: number }>(items: T[]): Map<number, T> {
  return new Map(items.map((i) => [i.id, i]));
}

// keyof + genérico: acceso a propiedades con seguridad total
function prop<T, K extends keyof T>(obj: T, clave: K): T[K] {
  return obj[clave];
}
const titulo = prop(tarea, 'title');    // tipo: string
// prop(tarea, 'nope');                 // Error: 'nope' no es keyof Task

// Genérico con valor por defecto y varios parámetros
interface Pagina<T, M = { total: number }> {
  items: T[];
  meta: M;
}

// Clase genérica: así está tipado EntityRepository de MikroORM
class Repositorio<T extends { id: number }> {
  private datos = new Map<number, T>();
  guardar(e: T): void { this.datos.set(e.id, e); }
  buscar(id: number): T | undefined { return this.datos.get(id); }
}

1.14.5 Tipos avanzados: mapeados, condicionales e infer

Utility typeQué haceEjemplo
Partial<T>Todas las propiedades opcionalesDTO de actualización
Required<T>Todas obligatoriasConfiguración ya resuelta
Readonly<T>Todas de solo lecturaEstado inmutable
Pick<T,K>Selecciona propiedadesProyección de una entidad
Omit<T,K>Excluye propiedadesDTO de creación sin id
Record<K,V>Diccionario tipadoMapa de traducciones
ReturnType<F>Tipo devuelto por una funciónDerivar tipos de factorías
Parameters<F>Tupla de argumentosEnvolver funciones
Awaited<P>Desenvuelve promesasTipo de un await
NonNullable<T>Quita null y undefinedTras validar
tipos-avanzados.ts
interface Task {
  id: number;
  title: string;
  description?: string;
  createdAt: Date;
  project: Project;
}

// 1) Tipos derivados: una sola fuente de verdad.
//    Si añades un campo a Task, los DTOs se actualizan solos.
type CreateTaskDto = Omit<Task, 'id' | 'createdAt' | 'project'> & { projectId: number };
type UpdateTaskDto = Partial<CreateTaskDto>;
type TaskListItem  = Pick<Task, 'id' | 'title'>;

// 2) Tipo mapeado con modificadores: quitar readonly y opcionalidad
type Mutable<T> = { -readonly [K in keyof T]-?: T[K] };

// 3) Tipo condicional con infer: extraer el tipo interno de un contenedor
type Desenvolver<T> = T extends Promise<infer U> ? U
                    : T extends Array<infer U>   ? U
                    : T;
type A = Desenvolver<Promise<Task>>;   // Task
type B = Desenvolver<Task[]>;          // Task

// 4) Template literal types: cadenas validadas por el compilador
type Metodo = 'get' | 'post' | 'put' | 'delete';
type Ruta   = `/${string}`;
type Endpoint = `${Uppercase<Metodo>} ${Ruta}`;
const e: Endpoint = 'GET /tasks';       // ✔
// const x: Endpoint = 'FETCH tasks';   // ✘ error de compilación

// 5) Tipo recursivo: DeepPartial, muy usado en configuración y tests
type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;

// 6) satisfies: valida la forma SIN ensanchar el tipo inferido
const config = {
  puerto: 3000,
  entorno: 'production',
} satisfies { puerto: number; entorno: 'development' | 'production' };
config.entorno;   // tipo literal 'production', no string genérico
Principio de diseño: DRY también en los tipos Si escribes a mano una interfaz TaskDto que repite los campos de Task, has creado dos fuentes de verdad que se desincronizarán. Deriva siempre que puedas con Pick, Omit y compañía. La excepción consciente: el contrato público de una API, que a veces quieres que esté desacoplado de la entidad para poder cambiar la base de datos sin romper clientes (capítulo 20).

1.14.6 Los tipos de utilidad, con un caso real de cada uno

utilidades.ts
type ActualizarTarea = Partial<Task>;                          // cuerpo de un PATCH: todo opcional
type ConfigResuelta  = Required<AppConfig>;                    // configuración tras aplicar defectos
type EstadoUi        = Readonly<{ cargando: boolean }>;        // estado que nadie debe mutar
type ItemLista       = Pick<Task, 'id' | 'title'>;             // proyección ligera para el listado
type CrearTarea      = Omit<Task, 'id' | 'createdAt'>;         // POST: sin los campos que genera el servidor
type Traducciones    = Record<Idioma, string>;                 // diccionario obligatorio para cada idioma
type Filtros         = ReturnType<typeof construirFiltros>;    // no repetir el tipo que devuelve una factoría
type ArgsBuscar      = Parameters<TasksService['buscar']>;     // envolver o espiar un método existente
type Datos           = Awaited<ReturnType<typeof api.get>>;    // el tipo que sale de un await
type Descripcion     = NonNullable<Task['description']>;       // después de comprobar que no es nula
type Activos         = Exclude<Estado, 'cancelada' | 'hecha'>; // quitar variantes de una unión
type EnCurso         = Extract<Estado, 'pendiente' | 'en_curso'>; // quedarse solo con algunas

1.14.7 Tipos de marca: dos identificadores que no se pueden confundir

El tipado estructural tiene un punto ciego: UserId y ProjectId, si ambos son number, son el mismo tipo para el compilador. Pasar uno donde va el otro compila sin protestar y produce el peor error posible, el que borra o modifica la fila equivocada. La solución es el tipo de marca (branded type): añadir al tipo primitivo una propiedad fantasma que no existe en tiempo de ejecución pero que el compilador sí distingue.

marcas.tsPATRÓN PROFESIONAL
declare const marca: unique symbol;
type Marca<T, Nombre extends string> = T & { readonly [marca]: Nombre };

type UserId    = Marca<number, 'UserId'>;
type ProjectId = Marca<number, 'ProjectId'>;
type Email     = Marca<string, 'Email'>;

// Única puerta de entrada: aquí es donde se valida de verdad, en runtime.
const comoUserId = (n: number): UserId => n as UserId;
function comoEmail(v: string): Email {
  if (!/^[^@\s]+@[^@\s]+$/.test(v)) throw new Error('Email inválido');
  return v as Email;                    // la aserción está justificada: acabamos de comprobarlo
}

function borrarUsuario(id: UserId): Promise<void> { /* ... */ }

borrarUsuario(comoUserId(7));           // ✔
// borrarUsuario(7);                    // ✘ 'number' no es asignable a 'UserId'
// borrarUsuario(projectId);            // ✘ el error que antes se descubría en producción

// Coste en tiempo de ejecución: cero. La marca desaparece al compilar.

1.14.8 Varianza, strictFunctionTypes y sobrecargas

Cuando un tipo contiene funciones, la pregunta «¿es A asignable a B?» deja de ser obvia. La regla correcta es que los parámetros son contravariantes (una función que acepta un tipo más general puede sustituir a una que acepta uno más específico) y los retornos son covariantes. La opción strictFunctionTypes, incluida en strict, es la que hace que el compilador aplique esa regla en lugar de la comprobación laxa que TypeScript arrastraba por compatibilidad.

varianza.ts
interface Evento { tipo: string }
interface Click extends Evento { x: number; y: number }

type Manejador<T> = (e: T) => void;
declare let manejaEvento: Manejador<Evento>;   // sabe atender CUALQUIER evento
declare let manejaClick:  Manejador<Click>;    // exige que el evento tenga x e y

manejaClick = manejaEvento;   // ✔ correcto: quien sabe atender todo, sabe atender un click
// manejaEvento = manejaClick; // ✘ con strictFunctionTypes: recibiría un Evento sin x ni y

// Excepción que sorprende: la sintaxis de MÉTODO sigue siendo bivariante
interface ConMetodo { on(e: Evento): void }        // no se comprueba a fondo (compatibilidad)
interface ConPropiedad { on: (e: Evento) => void } // sí se comprueba: prefiérela en tus interfaces

// Sobrecargas: varias firmas públicas para una sola implementación.
function buscar(id: number): Promise<Task>;
function buscar(filtro: FiltroTask): Promise<Task[]>;
function buscar(arg: number | FiltroTask): Promise<Task | Task[]> {
  return typeof arg === 'number' ? repo.porId(arg) : repo.porFiltro(arg);
}
const una  = await buscar(1);          // Task
const list = await buscar({ estado: 'pendiente' });   // Task[]
// La firma de implementación NO es visible para quien llama: si solo dejas una unión
// como firma pública, el llamador tendrá que estrechar el resultado a mano.

1.14.9 Qué no puede hacer TypeScript

Confundir lo que garantiza el compilador con lo que garantiza el programa es el origen de una parte enorme de los incidentes en producción. Conviene tenerlo escrito:

Lo que mucha gente creeLa realidadQué hacer en su lugar
«Si compila, los datos tienen esa forma»Los tipos no existen en tiempo de ejecución: no hay ninguna comprobación cuando llega un JSONValidar en la frontera con class-validator o Zod (capítulo 10)
«res.json() as Task[] me da tareas»Una aserción es una promesa tuya al compilador, no una comprobación. Si mientes, fallará más adelante y en otro sitioUn type guard o un esquema que valide y devuelva el tipo
«process.env.PORT es un número»Todo lo que viene del entorno es string | undefinedUn módulo de configuración que lea, valide y convierta una sola vez
«El tipo de la entidad garantiza lo que hay en la base de datos»La base de datos puede tener nulos, columnas nuevas o datos antiguos que no cumplen el tipoMigraciones y restricciones en el esquema (capítulos 17 y 19)
«arr[0] siempre existe»Sin noUncheckedIndexedAccess, el compilador miente sobre los índicesActivar esa opción y comprobar el undefined
«Un any aislado no hace daño»any se propaga: contamina todo lo que toca y desactiva la comprobación río abajounknown y estrechamiento explícito

1.15 Decoradores y metadatos: el motor oculto del stack

1.15.1 Qué es realmente un decorador

Un decorador es una función que recibe el elemento decorado y puede observarlo, modificarlo o registrar información sobre él. No es magia: es una llamada a función que ocurre cuando se define la clase, no cuando se instancia.

decorador-basico.ts
// Decorador de clase: recibe el constructor
function Registrable(target: Function) {
  console.log('Clase definida:', target.name);
}

// Decorador de método: envuelve el comportamiento original
function Medir(_t: unknown, clave: string, desc: PropertyDescriptor) {
  const original = desc.value;
  desc.value = function (...args: unknown[]) {
    const t0 = performance.now();
    const r = original.apply(this, args);
    console.log(`${clave} tardó ${(performance.now() - t0).toFixed(1)} ms`);
    return r;
  };
  return desc;
}

// Decorador con parámetros: en realidad es una FÁBRICA de decoradores.
// Por eso @Component({...}) lleva paréntesis y @Injectable() también.
function Rol(...roles: string[]) {
  return function (target: object, clave: string) {
    Reflect.defineMetadata('roles', roles, target, clave);
  };
}

@Registrable
class Servicio {
  @Medir
  @Rol('admin')
  procesar() { /* ... */ }
}

1.15.2 reflect-metadata y la inyección de dependencias

Aquí está la pieza clave que conecta TypeScript con NestJS, Angular y MikroORM. Como los tipos se borran al compilar, ¿cómo sabe Nest que el constructor necesita un UsersService? Porque el compilador, con la opción emitDecoratorMetadata activada, emite los tipos como metadatos en tiempo de ejecución para las clases decoradas.

metadata.ts · qué genera realmente el compilador
// ---------- Lo que escribes ----------
@Injectable()
export class TasksService {
  constructor(private readonly users: UsersService,
              private readonly em: EntityManager) {}
}

// ---------- Lo que aproximadamente se genera ----------
let TasksService = class TasksService {
  constructor(users, em) { this.users = users; this.em = em; }
};
TasksService = __decorate([
  Injectable(),
  // ESTA línea es la clave: guarda los tipos del constructor como metadatos
  __metadata('design:paramtypes', [UsersService, EntityManager]),
], TasksService);

// ---------- Cómo lo lee el contenedor de DI ----------
const tipos = Reflect.getMetadata('design:paramtypes', TasksService);
// [UsersService, EntityManager]  → busca cada uno en el inyector y construye la instancia
Consecuencia práctica número uno: no puedes inyectar una interfaz

Las interfaces no existen en runtime, así que design:paramtypes registraría Object y el contenedor no sabría qué inyectar. Solución: usar un token.

nest · DI con interfazINCORRECTO
interface PasarelaPago { cobrar(x: number): Promise<void> }

@Injectable()
export class PedidosService {
  // Nest no puede resolver esto: la interfaz no existe en runtime.
  // Error: Nest can't resolve dependencies of PedidosService (?)
  constructor(private pasarela: PasarelaPago) {}
}
nest · DI con tokenCORRECTO
export const PASARELA_PAGO = Symbol('PASARELA_PAGO');
export interface PasarelaPago { cobrar(x: number): Promise<void> }

@Injectable()
export class PedidosService {
  constructor(@Inject(PASARELA_PAGO) private pasarela: PasarelaPago) {}
}

// En el módulo: se elige la implementación concreta.
// Esto es Inversión de Dependencias (la D de SOLID) de manual.
@Module({
  providers: [{ provide: PASARELA_PAGO, useClass: StripeGateway }],
})
export class PedidosModule {}
Decoradores «legacy» vs decoradores estándar Los decoradores llegaron a ECMAScript como estándar (etapa 3) con una semántica distinta de la implementación experimental que usan Angular, Nest y MikroORM. En 2026 estos frameworks siguen apoyándose en el modelo con experimentalDecorators y emitDecoratorMetadata porque necesitan los metadatos de tipos, que el estándar no emite. Si un tutorial te dice que desactives esas opciones, tu inyección de dependencias dejará de funcionar.

1.16 Configuración: tsconfig.json explicado

tsconfig.json · configuración recomendada y comentada
{
  "compilerOptions": {
    /* --- Salida --- */
    "target": "ES2022",              // nivel de JS generado; ES2022 da campos privados nativos
    "module": "NodeNext",            // en Angular: "preserve"/"ES2022" (lo gestiona el CLI)
    "moduleResolution": "NodeNext",
    "lib": ["ES2023", "DOM"],        // en backend, quita "DOM": evita usar window por error
    "outDir": "./dist",
    "sourceMap": true,               // imprescindible para depurar y para trazas legibles

    /* --- Decoradores: obligatorio en Nest y MikroORM --- */
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,

    /* --- Rigor: activa TODO y agradécelo en producción --- */
    "strict": true,                  // incluye las 8 opciones siguientes
    "noImplicitAny": true,
    "strictNullChecks": true,        // la más valiosa: separa T de T | null
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "useUnknownInCatchVariables": true,
    "alwaysStrict": true,

    /* --- Rigor adicional muy recomendable --- */
    "noUncheckedIndexedAccess": true,   // arr[0] pasa a ser T | undefined (evita crasheos)
    "noImplicitOverride": true,         // obliga a escribir 'override'
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "exactOptionalPropertyTypes": true, // distingue "ausente" de "presente pero undefined"
    "forceConsistentCasingInFileNames": true,

    /* --- Interoperabilidad y rutas --- */
    "esModuleInterop": true,
    "skipLibCheck": true,               // no valida los .d.ts de node_modules: compila mucho más rápido
    "resolveJsonModule": true,
    "baseUrl": "./",
    "paths": {
      "@app/*":    ["src/*"],
      "@shared/*": ["libs/shared/src/*"]
    }
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
strictPropertyInitialization y las entidades de MikroORM Con strict activo, el compilador exige inicializar toda propiedad no opcional. En una entidad, el valor lo pone el ORM al hidratar desde la base de datos, no tu código. Por eso las entidades usan el operador de aserción definida: id!: number. Es correcto y esperado; no lo confundas con el ! abusivo que oculta errores reales en la lógica de negocio.

1.17 Herramientas: transpilar, compilar y depurar

Compilar es traducir de un lenguaje a otro de nivel distinto (de C a código máquina). Transpilar es traducir entre lenguajes del mismo nivel de abstracción: de TypeScript a JavaScript, o de JavaScript moderno a JavaScript antiguo. Lo que hace tsc son en realidad dos trabajos independientes que conviene separar mentalmente: comprobar los tipos y emitir JavaScript. Esa separación es la clave de todo el ecosistema moderno de herramientas.

HerramientaEscrita en¿Comprueba tipos?Papel habitual
tscTypeScript, es la única que lo hace de verdadVerificación en el editor y en integración continua; compilación en proyectos pequeños
esbuildGoNo: borra los tipos sin mirarlosEmpaquetado y transpilación en Angular y Vite; construcciones de producción
SWCRustNoTranspilación en NestJS (nest build --builder swc) y en Jest para acelerar los tests
BabelJavaScriptNoTransformaciones muy personalizadas; hoy en retroceso frente a las dos anteriores

Que esbuild y SWC sean uno o dos órdenes de magnitud más rápidos no se debe solo al lenguaje en que están escritos: hacen mucho menos trabajo. Al no comprobar tipos no construyen el grafo de tipos del proyecto ni resuelven la semántica de cada expresión, así que pueden procesar los ficheros de forma casi independiente y repartirlos entre núcleos. Es un intercambio deliberado: velocidad a cambio de que la seguridad de tipos la aporte otro proceso.

Consecuencia práctica: tsc --noEmit es obligatorio en el pipeline Si tu compilación de producción la hace esbuild o SWC, nadie está comprobando los tipos salvo tu editor. Un error de tipos puede llegar a producción sin que ninguna fase falle. Por eso todo proyecto serio ejecuta tsc --noEmit como paso independiente de integración continua, junto al linter y a los tests: comprueba y no genera nada, así que es puro control de calidad.
package.json · scripts de verificación
# Verificación de tipos, sin generar ficheros (rápido y sin efectos secundarios)
tsc --noEmit -p tsconfig.json

# En modo vigilancia durante el desarrollo, en una terminal aparte
tsc --noEmit --watch

# Traza legible en producción: Node aplica los source maps a los stack traces
node --enable-source-maps dist/main.js

Los source maps son el puente entre el código que se ejecuta y el que escribiste: un fichero .map con una tabla que traduce cada posición del JavaScript generado a la línea y columna del TypeScript original. Sin ellos, un error en producción apunta a la línea 1 columna 48.213 de un fichero minificado. Con ellos, el depurador del navegador, el de tu editor y los servicios de seguimiento de errores te muestran el fichero real. En el navegador conviene generarlos y subirlos al servicio de errores sin publicarlos junto al paquete (evitas exponer el código fuente); en el servidor, la opción --enable-source-maps de Node basta para que las trazas sean legibles, con un coste de arranque pequeño y asumible.

1.18 Errores comunes y cómo solucionarlos

ErrorCausa realSolución
Cannot read properties of undefined Se accede a un dato asíncrono antes de que llegue, o se perdió this Encadenamiento opcional ?., valores iniciales, strictNullChecks, funciones flecha
Object is possibly 'null' strictNullChecks haciendo su trabajo Comprobar explícitamente o usar ?.; evita el ! automático
Type 'string' is not assignable to type '"a" | "b"' Ensanchamiento de tipo literal as const, satisfies o anotar el tipo explícitamente
Nest can't resolve dependencies El provider no está en providers ni exportado por el módulo importado, o es una interfaz Exportar el provider, importar el módulo, o usar un token con @Inject
Circular dependency detected Dos módulos o servicios se importan mutuamente forwardRef como parche; extraer un módulo compartido como solución real
UnhandledPromiseRejection Promesa sin await ni catch Regla ESLint no-floating-promises; siempre await o .catch()
Cambio de estado que no refresca la vista Mutación en lugar de nueva referencia Actualizaciones inmutables: [...arr], {...obj}
Maximum call stack size exceeded Recursión infinita, a menudo por un toJSON circular en entidades Cortar ciclos al serializar; usar DTOs en lugar de entidades
Cannot convert undefined or null to objectObject.keys/entries sobre un valor que no llegóComprobar antes, o Object.keys(x ?? {})
Un [object Object] en un log o en una URLSe ha concatenado un objeto con una cadenaLogger estructurado o JSON.stringify; nunca concatenar objetos
Totales con céntimos que no cuadranAritmética en coma flotante (0.1 + 0.2)Trabajar en enteros (céntimos) o con un tipo decimal; formatear solo al mostrar
Identificadores que cambian de valor solosUn BIGINT supera Number.MAX_SAFE_INTEGERTransportarlos como cadena; bigint en el código si hace falta operar
Cannot find module '@app/...' solo en producciónLos paths de tsconfig no existen en tiempo de ejecuciónEmpaquetar, usar tsconfig-paths o los imports del package.json
ERR_REQUIRE_ESM · Cannot use import statement outside a moduleMezcla de CommonJS y ESMAlinear type del package.json con module de tsconfig; importación dinámica como último recurso
JavaScript heap out of memoryLote demasiado grande o fuga por retenciónStreams o generadores asíncronos, em.clear(), instantáneas del montículo
MaxListenersExceededWarningSe registra un listener en cada peticiónRegistrarlo una sola vez al arrancar; { once: true } cuando proceda
Latencia alta con la CPU casi ociosaEl event loop está bloqueado por trabajo síncronoMedir con monitorEventLoopDelay; mover el cálculo a un worker o a una cola
Reflect.getMetadata is not a functionFalta import 'reflect-metadata' o falta emitDecoratorMetadataImportarlo una única vez en el punto de entrada y revisar el tsconfig.json

1.19 Buenas y malas prácticas

Haz esto

  • strict: true desde el minuto uno. Añadirlo después a un proyecto grande cuesta semanas.
  • Prefiere unknown a any en los límites del sistema (JSON, catch, librerías sin tipos).
  • Deriva tipos en lugar de duplicarlos (Pick, Omit, ReturnType).
  • Uniones de literales antes que enum: sin código generado y con mejor narrowing.
  • Inmutabilidad por defecto: readonly, const, copias en lugar de mutaciones.
  • Funciones pequeñas y puras siempre que sea posible: son testeables y optimizables.
  • Nombres que revelan intención: tareasVencidas, no data2.
  • Async por defecto en E/S; nada de variantes *Sync en un servidor.
  • Valida en la frontera. Todo lo que entra (cuerpo HTTP, variables de entorno, respuestas de terceros) se valida en tiempo de ejecución antes de tocar la lógica.
  • Limita la concurrencia en cualquier proceso por lotes, y cierra siempre lo que abras: temporizadores, listeners, streams, suscripciones y conexiones.

Evita esto

  • any por comodidad. Cada any es un agujero por el que se cuelan errores en producción.
  • as para «callar» al compilador. Una aserción falsa es una mentira que explota más tarde.
  • ! no justificado. Si sabes que no es nulo, demuéstralo con una comprobación.
  • Efectos secundarios ocultos en getters o en funciones con nombre de consulta.
  • console.log como sistema de logging en el backend: usa un logger estructurado.
  • Capturar errores para silenciarlos. O lo resuelves, o lo propagas con contexto.
  • Abusar de la herencia. Composición y funciones antes que jerarquías profundas.
  • == salvo el idiomático x == null.
  • Trabajo de CPU en el hilo principal. Un bucle pesado, un cifrado síncrono o una expresión regular sin acotar congelan todas las peticiones a la vez.
  • JSON.parse(JSON.stringify(x)) para clonar y Promise.all sobre listas de tamaño desconocido: dos atajos que fallan justo cuando crece el volumen.

1.20 Gestión de dependencias: npm, semver y lockfile

NotaciónPermite actualizar a…Riesgo
1.2.3Exactamente esa versiónNinguno; requiere mantenimiento manual
~1.2.3Parches: 1.2.xBajo
^1.2.3Menores y parches: 1.x.xMedio (por defecto en npm)
* o latestCualquier cosaAlto: no lo uses

1.21 Preguntas frecuentes

¿TypeScript hace mi aplicación más lenta?
No en ejecución: los tipos se borran y el JavaScript generado es equivalente al que habrías escrito a mano. Lo que sí añade es tiempo de compilación en desarrollo y CI. A cambio elimina toda una categoría de errores en tiempo de ejecución.
Si los tipos se borran, ¿de qué me sirven en producción?
Los tipos son un contrato verificado antes de desplegar: garantizan coherencia interna. Lo que no hacen es validar datos externos. Un JSON que llega de un cliente puede tener cualquier forma aunque lo declares como CreateTaskDto. Por eso el backend necesita validación en runtime (class-validator o Zod, capítulo 10): los tipos protegen tu código, la validación protege tu sistema.
¿interface o type?
Ambos sirven para describir la forma de un objeto. interface admite declaration merging (varias declaraciones se fusionan) y se extiende con extends: es la opción idiomática para contratos de objetos y para ampliar tipos de librerías. type es necesario para uniones, intersecciones, tuplas, tipos condicionales y mapeados. Regla práctica: objetos con interface, todo lo demás con type, y sé consistente dentro del proyecto.
¿Por qué enum tiene mala fama?
Porque genera código JavaScript real (un objeto de doble mapeo), no se puede eliminar con tree shaking, tiene un comportamiento sorprendente con los enums numéricos (se aceptan números arbitrarios) y no es compatible con el modo isolatedModules en su forma const enum. Una unión de literales (type Estado = 'a' | 'b') es más ligera y da mejor narrowing. Excepción razonable: cuando el ORM mapea a un enum nativo de la base de datos y quieres una única fuente de verdad.
¿Cuándo uso Promise y cuándo un Observable?
Promesa: un valor futuro, no cancelable, se ejecuta al crearse. Observable: cero, uno o muchos valores en el tiempo, cancelable, perezoso (no hace nada hasta que alguien se suscribe) y componible con operadores. En Angular, HttpClient devuelve observables incluso para una única respuesta, precisamente porque permite cancelar (esencial en un buscador) y reintentar. Ver capítulo 4.
¿Qué diferencia hay entre undefined y null?
Por convención: undefined es «no se ha asignado» (lo pone el motor); null es «ausencia deliberada» (lo pones tú). En bases de datos, null mapea a NULL. Elige una convención en tu equipo y respétala; en este libro se usa undefined para propiedades opcionales de TypeScript y null para columnas anulables.
¿Es obligatorio saber RxJS para usar Angular hoy?
Menos que antes, pero sí. Las señales cubren el estado síncrono de la interfaz, pero HttpClient, los eventos del router, los formularios (valueChanges) y cualquier flujo con tiempo (debounce, reintentos, websockets) siguen siendo observables. Lo veremos en el capítulo 4 con la lista mínima de operadores que realmente necesitas.
Explica el event loop en un minuto. ¿Por qué setTimeout(fn, 0) no es inmediato?
JavaScript ejecuta una tarea completa sin interrupciones; cuando la pila queda vacía, vacía entera la cola de microtareas (promesas) y solo después toma la siguiente macrotarea. Un setTimeout es una macrotarea, así que se ejecuta detrás de todo el código síncrono y de todas las promesas pendientes; el retardo que indicas es un mínimo, no una cita. En Node, además, cada macrotarea pertenece a una fase concreta del bucle de libuv (timers, poll, check), y entre fase y fase se vacían process.nextTick y las microtareas.
¿Cómo explicarías this a alguien que viene de Java?
En Java this es la instancia y punto. En JavaScript se decide en la llamada: con new es el objeto nuevo, con call/apply/bind es el que pasas, con obj.metodo() es lo que hay a la izquierda del punto y en una llamada suelta es undefined en modo estricto. Las funciones flecha no tienen this propio: usan el del ámbito donde se escribieron, y por eso son la solución idiomática para los callbacks.
¿Qué es un closure y para qué se usa realmente?
Es una función junto con el entorno léxico donde se creó: sigue viendo esas variables aunque la función que las declaró ya haya terminado. Se usa para estado privado (contadores, memoización, fábricas), para inyectar configuración en un manejador y en cualquier callback que necesite recordar algo. Su cara B es la memoria: el entorno capturado no se libera mientras la función viva, así que un closure que retiene un componente o una entidad grande es la causa más común de fuga.
Si class existe, ¿para qué necesito saber de prototipos?
Porque class es azúcar sintáctico: por debajo sigue habiendo objetos enlazados por [[Prototype]]. Saberlo explica por qué se puede añadir un método a todas las instancias existentes modificando el prototipo, por qué instanceof recorre una cadena, por qué un objeto plano tiene toString y por qué al «clonar» una entidad con el operador de propagación pierdes sus métodos: la copia es un objeto plano, no una instancia.
¿Por qué se prohíbe == si a veces es cómodo?
Porque su algoritmo de conversión produce resultados que nadie recuerda: 0 == '', '0' == false y [] == ![] son todos true. Revisar código con == exige razonar sobre coerciones en lugar de sobre lógica de negocio. La única excepción aceptada es x == null, que comprueba null y undefined a la vez; para lo demás, === siempre.
¿Cuándo uso Map en vez de un objeto literal?
Siempre que las claves sean dinámicas o no sean cadenas, cuando necesites saber el tamaño en tiempo constante, cuando importe el orden de inserción o cuando vayas a insertar y borrar mucho. El objeto literal es mejor para estructuras fijas y conocidas, y para lo que vaya a serializarse a JSON directamente. Recuerda además que un objeto hereda propiedades (constructor, toString) que pueden colisionar con tus claves, y que las claves numéricas se reordenan.
Tengo cien elementos que procesar contra una API. ¿Bucle con await o Promise.all?
Ninguno de los dos: el bucle tarda la suma de todos los tiempos y Promise.all lanza cien peticiones simultáneas que agotarán el pool de conexiones o el límite de peticiones del proveedor. La respuesta profesional es concurrencia limitada (entre cinco y diez trabajadores tomando elementos de una cola compartida), con reintentos solo para los errores transitorios. Promise.all es correcto cuando la lista es pequeña y conocida, como las tres llamadas de un panel.

1.22 Ejercicios

Nivel 1 · básico

1.1 Escribe en un papel la salida exacta de este programa antes de ejecutarlo. Después compruébalo:

console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => { console.log('C'); return Promise.resolve(); })
                 .then(() => console.log('D'));
(async () => { console.log('E'); await 0; console.log('F'); })();
console.log('G');

1.2 Implementa agrupar<T, K extends string>(items: T[], clave: (i: T) => K): Record<K, T[]> con tipos correctos y sin any.

1.3 Dada interface Usuario { id: number; email: string; password: string; creado: Date }, define con utility types: el DTO de creación (sin id ni creado), el de actualización (todo opcional salvo el id) y el de respuesta pública (sin password).

Nivel 2 · intermedio

1.4 Escribe conReintentos<T>(fn: () => Promise<T>, intentos: number, esperaMs: number): Promise<T> con retroceso exponencial, que relance el último error si se agotan los intentos.

1.5 Implementa conTimeout<T>(p: Promise<T>, ms: number): Promise<T> usando Promise.race y un AbortController. ¿Qué pasa con la promesa perdedora? ¿Se cancela realmente?

1.6 Crea un type guard esRespuestaApi<T>(x: unknown): x is { ok: true; data: T } y úsalo para procesar una respuesta de fetch sin ningún as.

1.7 Escribe un decorador @Cachear(ttlMs) para métodos, que memoice el resultado por argumentos durante un tiempo. Explica qué ocurre si el método es asíncrono y lanza un error.

Nivel 3 · avanzado

1.8 Implementa type RutasDe<T> que, dado un objeto anidado, produzca la unión de todas sus rutas en notación de punto ('usuario.direccion.ciudad'). Necesitarás tipos recursivos y template literal types.

1.9 Implementa una cola con concurrencia limitada: mapaConcurrente<T, R>(items: T[], n: number, fn: (t: T) => Promise<R>): Promise<R[]> que nunca ejecute más de n tareas a la vez y conserve el orden de los resultados.

1.10 Usando AsyncLocalStorage, construye un mini-sistema de contexto de petición con identificador de correlación e inyéctalo en un logger. Comprueba que el identificador se conserva a través de await, setTimeout y Promise.all.

1.11 Procesa un CSV de varios millones de filas y cuenta cuántas hay de cada estado sin superar los 100 MB de memoria residente. Imprime process.memoryUsage().heapUsed cada 100.000 filas y compara el resultado con la versión ingenua que usa readFile y split('\n').

1.12 Instrumenta un servicio con monitorEventLoopDelay y expón el percentil 99 como métrica. Provoca después un bloqueo síncrono de 500 ms, observa el efecto en la métrica y en la latencia de las peticiones, y resuélvelo moviendo el cálculo a un worker_thread. Documenta las tres medidas.

Pistas y soluciones comentadas (ejercicios 1.1 y 1.4)

1.1 · Salida: A E G C F D B. Razonamiento: síncrono primero (A, E, G); luego las microtareas en orden de encolado (C se encoló antes que F; D se encola cuando C termina, y además devolver una promesa dentro de un then añade dos saltos de microtarea, por lo que D queda detrás de F); finalmente la macrotarea B.

1.4 · Esquema de solución:

async function conReintentos<T>(fn: () => Promise<T>, intentos = 3, esperaMs = 200): Promise<T> {
  let ultimo: unknown;
  for (let i = 0; i < intentos; i++) {
    try {
      return await fn();
    } catch (e) {
      ultimo = e;
      // No esperes tras el último intento: sería tiempo perdido
      if (i < intentos - 1) {
        const espera = esperaMs * 2 ** i + Math.random() * 100;  // backoff + jitter
        await new Promise((r) => setTimeout(r, espera));
      }
    }
  }
  throw ultimo;
}

El jitter aleatorio evita que mil clientes reintenten exactamente a la vez y tumben el servicio que se está recuperando (efecto «manada atronadora»).

Soluciones comentadas (ejercicios 1.3 y 1.11)

1.3 · Tipos derivados. Ningún campo se escribe dos veces, así que si mañana cambia Usuario los tres DTO se ajustan solos: type CrearUsuarioDto = Omit<Usuario, 'id' | 'creado'>, type ActualizarUsuarioDto = Partial<CrearUsuarioDto> & Pick<Usuario, 'id'> y type UsuarioPublicoDto = Omit<Usuario, 'password'>.

1.11 · Memoria constante. El error habitual es acumular las filas «solo para contarlas». Con un stream nunca hay más de un puñado de líneas en memoria, así que el consumo depende del tamaño del búfer y no del fichero. Fíjate en que el contador es un Map: las claves son dinámicas.

import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';

const porEstado = new Map<string, number>();
let n = 0;

const lineas = createInterface({
  input: createReadStream(ruta, { encoding: 'utf8', highWaterMark: 64 * 1024 }),
  crlfDelay: Infinity,
});

for await (const linea of lineas) {
  const estado = linea.split(',')[3] ?? 'desconocido';
  porEstado.set(estado, (porEstado.get(estado) ?? 0) + 1);
  if (++n % 100_000 === 0) {
    const mb = process.memoryUsage().heapUsed / 1024 / 1024;
    console.log(`${n} filas · heap ${mb.toFixed(1)} MB`);   // se mantiene plano
  }
}

La versión con readFile necesita el tamaño del fichero en memoria, más el array resultante del split, más las cadenas de cada línea: para un fichero de 2 GB son varios gigabytes de montículo y un heap out of memory garantizado.

1.23 Resumen del capítulo

  • Un solo hilo, muchas colas. El event loop ejecuta todo lo síncrono, luego vacía las microtareas (promesas) y después toma una macrotarea (timers, E/S). Bloquear el hilo en el backend congela todas las peticiones.
  • Primitivos por valor, objetos por referencia. De ahí depende la reactividad de Angular (referencias nuevas) y el change tracking de MikroORM (misma referencia, mutación detectada).
  • this depende de la llamada, salvo en funciones flecha, que lo capturan léxicamente.
  • Los closures dan estado privado y son la causa habitual de las fugas de memoria.
  • TypeScript es estructural y sus tipos se borran. Protegen tu código, no tus datos: la validación en runtime sigue siendo obligatoria en el servidor.
  • Genéricos y tipos derivados evitan duplicar definiciones y mantienen una única fuente de verdad.
  • Los decoradores son funciones que se ejecutan al definir la clase; emitDecoratorMetadata es lo que permite la inyección de dependencias por tipo, y por eso no se pueden inyectar interfaces sin un token.
  • strict: true no es opcional en un proyecto profesional.
  • El motor premia la previsibilidad. Formas de objeto estables, tipos homogéneos y pocas referencias retenidas: el mismo código que es legible es el que se optimiza bien y no fuga memoria.
  • Elige la estructura de datos por su coste, no por costumbre: Set y Map resuelven en tiempo constante lo que un array resuelve recorriéndolo entero.
  • Los datos grandes se procesan por partes, con streams o iteradores asíncronos, y las listas de tamaño desconocido con concurrencia limitada.

1.24 Recursos adicionales

Siguiente paso Con estos fundamentos ya puedes entender por qué Angular hace lo que hace. El capítulo 2 abre la Parte II con la arquitectura del framework, el CLI y la estructura de un proyecto real.