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.
1.1 Qué vas a poder hacer al terminar
- Predecir el orden exacto de salida de un programa con
setTimeout, promesas yasync/await, y explicar por qué. - Explicar qué es una referencia y por qué mutar un array rompe la reactividad de Angular.
- Usar el sistema de tipos como herramienta de diseño: genéricos, tipos condicionales,
infery tipos derivados en lugar de duplicar definiciones. - Explicar qué hace exactamente un decorador y por qué NestJS y MikroORM dependen de
reflect-metadata. - Configurar
tsconfig.jsoncon criterio y entender el impacto de cada opción estricta.
1.2 Historia y contexto: de un lenguaje de 10 días al stack completo
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:
| Fase | Qué hace | Consecuencia 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ón | Si 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:
- Las «hidden classes» importan. V8 crea internamente una clase oculta por «forma» de objeto. Si añades propiedades a un objeto en distinto orden o de forma condicional, generas formas distintas y el código se desoptimiza. Por eso conviene inicializar todos los campos en el constructor.
- El polimorfismo excesivo penaliza. Una función que recibe objetos de diez formas distintas no se puede optimizar tan bien como una que recibe siempre la misma.
- La recolección de basura es generacional (espacio joven con scavenger, espacio viejo con marcado y compactación). Crear muchos objetos efímeros es barato; retenerlos por accidente (closures que capturan más de lo necesario, suscripciones no canceladas, cachés sin límite) es lo que provoca fugas.
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 cache | Formas vistas | Coste del acceso | Ejemplo típico |
|---|---|---|---|
| Monomórfica | 1 | Óptimo: una comprobación y una lectura por desplazamiento | Una función que solo recibe instancias de Task |
| Polimórfica | 2 a 4 | Aceptable: una pequeña lista de comprobaciones | Un renderizador que atiende tres tipos de nodo |
| Megamórfica | 5 o más | Malo: se abandona la cache y se consulta una tabla hash global | Un mapper genérico que recibe objetos literales de cualquier forma |
// 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');
// 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');
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:
- Crear objetos efímeros es barato. El coste del scavenger es proporcional a lo que sobrevive, no a lo que se crea. Por eso el estilo funcional (mapear, filtrar, crear objetos nuevos) no es intrínsecamente lento: la basura joven prácticamente no se paga.
- Lo caro es retener. Una caché en un
Mapque nunca expira, un array de logs que crece sin límite, una suscripción viva o un closure que captura un componente entero promocionan objetos al espacio viejo, donde cada recolección cuesta mucho más. Una fuga de memoria en JavaScript no es «memoria que no se libera», sino memoria que sigue siendo alcanzable sin que tú lo sepas. - El recolector detiene el hilo. Las pausas del espacio joven son de milisegundos, pero una recolección mayor de un montículo de 2 GB puede costar cientos de milisegundos durante los cuales tu API no responde. En un servicio con latencia exigente, controlar el tamaño del montículo es parte del diseño.
- El límite del montículo se configura. En un contenedor,
node --max-old-space-size=512ajusta el límite al de la memoria asignada; si no lo haces, Node puede intentar crecer más allá de lo que permite el orquestador y el proceso morirá con un OOM kill sin ningún error de JavaScript. Lo retomamos al hablar de despliegue en el capítulo 21.
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
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.
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.
| Origen | Tipo | Entorno | Nota |
|---|---|---|---|
Promise.then/catch/finally | Microtarea | Ambos | Incluye la continuación de cada await |
queueMicrotask() | Microtarea | Ambos | Forma explícita y recomendada de encolar una |
process.nextTick() | Cola propia, antes de las microtareas | Solo Node | Tiene prioridad sobre las promesas |
MutationObserver | Microtarea | Solo navegador | Cambios del DOM agrupados |
setTimeout / setInterval | Macrotarea | Ambos | El retardo es un mínimo, no una garantía |
setImmediate() | Macrotarea (fase check) | Solo Node | Se ejecuta tras la fase de poll del mismo ciclo |
| Callbacks de E/S (red, ficheros) | Macrotarea (fase poll) | Ambos | El grueso del trabajo de un servidor |
Eventos del DOM, postMessage | Macrotarea | Solo navegador | Un click es una tarea completa |
requestAnimationFrame | Cola propia, antes del pintado | Solo navegador | Se sincroniza con el refresco de pantalla |
Con esa tabla, las tres reglas que gobiernan todo el modelo son:
- 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.
- Al terminar cada tarea se vacía entera la cola de microtareas, no una sola microtarea.
- 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.
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.
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:
| Fase | Qué ejecuta | Qué debes saber |
|---|---|---|
| timers | Callbacks 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 callbacks | Callbacks de sistema aplazados del ciclo anterior, típicamente errores de TCP. | Fase interna: casi nunca la observarás. |
| idle, prepare | Uso interno de libuv. | No es accesible desde JavaScript. |
| poll | Espera 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. |
| check | Callbacks de setImmediate. | Significa «ejecuta esto tras la E/S de este ciclo, antes de los timers del siguiente». |
| close callbacks | Eventos close de sockets y manejadores destruidos. | Sitio natural para liberar los recursos de una conexión. |
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.
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).
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.
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.
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);
}
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.
// 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 frecuente | Orden de magnitud | Alternativa correcta |
|---|---|---|
JSON.parse / JSON.stringify de varios MB | Decenas o centenas de ms | Streaming del cuerpo, paginación, o mover la serialización a un worker |
bcrypt.hashSync con coste 12 | Centenas de ms por llamada | Versión asíncrona (usa el thread pool) |
fs.readFileSync de un fichero grande | Decenas de ms o más | fs.promises o streams con contrapresión |
| Expresión regular con retroceso catastrófico | De ms a minutos según la entrada | Reescribir la expresión, limitar la longitud de la entrada, usar un validador |
| Bucle sobre cientos de miles de filas en memoria | Centenas de ms | Hacer el trabajo en SQL (capítulo 19) o por lotes con setImmediate |
| Generar un PDF, un ZIP o una imagen | Segundos | Cola 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.
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
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ón | Resultado | Motivo |
|---|---|---|
0 == '0' | true | Coerción: la cadena se convierte a número |
0 == [] | true | [] → '' → 0 |
0 == '' | true | La cadena vacía se convierte a 0 |
'0' == false | true | Ambos lados acaban convertidos a número |
[] == ![] | true | ![] es false → 0, y [] → 0 |
null == 0 | false | null solo es laxamente igual a undefined |
null == undefined | true | Caso especial de la especificación |
null === undefined | false | Tipos distintos |
NaN === NaN | false | NaN no es igual a nada, ni a sí mismo |
Object.is(NaN, NaN) | true | Igualdad «SameValue» |
Object.is(0, -0) | false | SameValue distingue los dos ceros; === no |
{} === {} | false | Dos referencias distintas |
[1,2] == '1,2' | true | El 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.
{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'.
// 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!
// ?? 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.
// 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
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()
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.
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')
}
}
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));
}
}
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:
| # | Regla | Forma de la llamada | Valor de this |
|---|---|---|---|
| 1 | Con new | new Persona() | El objeto recién creado |
| 2 | Enlace explícito | f.call(o), f.apply(o), f.bind(o)() | El objeto que pasas |
| 3 | Enlace implícito | obj.metodo() | El objeto que está a la izquierda del punto |
| 4 | Por defecto | f() | 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.
'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'
}
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.
// 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.
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ón | Estado antes de su línea | Ámbito | Redeclarable |
|---|---|---|---|
var | Existe y vale undefined | Función | Sí (fuente de errores silenciosos) |
let | Existe pero en zona muerta temporal: usarla lanza ReferenceError | Bloque | No |
const | Igual que let, y exige inicializador | Bloque | No |
function (declaración) | Ya está definida y se puede llamar | Bloque en modo estricto | Sí |
class | Zona muerta temporal, como let | Bloque | No |
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
| Combinador | Se resuelve cuando… | Se rechaza cuando… | Caso de uso |
|---|---|---|---|
Promise.all | Todas cumplen (devuelve array de valores) | La primera falla (fail-fast) | Cargar datos obligatorios en paralelo |
Promise.allSettled | Todas terminan, con éxito o error | Nunca | Notificar a 5 servicios y saber cuáles fallaron |
Promise.race | La primera que termina (cumpla o falle) | Si la primera en terminar falla | Timeouts |
Promise.any | La primera que cumple | Solo si todas fallan (AggregateError) | Varios espejos o réplicas |
// 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 };
}
// 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.
| Estrategia | Duración | Carga sobre el servicio destino | Cuándo es la correcta |
|---|---|---|---|
for...of con await | Suma de todas | Una petición a la vez | Hay dependencia entre iteraciones, el orden importa o el destino es frágil |
Promise.all(map) | La más lenta | Todas a la vez | Pocos elementos, conocidos y acotados (tres o cuatro llamadas de un panel) |
| Concurrencia limitada | Total dividido entre N | Como mucho N a la vez | Listas grandes o de tamaño desconocido: el caso habitual en un backend |
// 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); });
// 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));
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
// 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 */ }
}
// 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 });
}
}
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.
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;
}
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ón | Estilo recomendado | Motivo |
|---|---|---|
| Secuencia de pasos con variables intermedias | async/await | Se 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 paralelo | Combinadores + await | await 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 condicionales | async/await | Encadenar 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.
// 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
});
}
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 DNS | Sí | Fallo de red típicamente transitorio |
HTTP 429 y 503 con cabecera Retry-After | Sí, respetando la cabecera | El servicio te está diciendo cuándo volver |
| HTTP 500 en una operación de solo lectura | Sí, con límite | Idempotente: repetirla no tiene efectos |
HTTP 500 en un POST de cobro | Solo con clave de idempotencia | Puede haberse ejecutado antes de fallar: cobrarías dos veces |
| HTTP 400, 401, 403, 404, 422 | No | El error es tuyo: repetirlo dará exactamente lo mismo |
| Error de validación o de lógica de negocio | No | Reintentar 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? | Valores | Nota |
|---|---|---|
| Sí | Array, String, Map, Set, TypedArray, arguments, NodeList, generadores | Un String se recorre por caracteres Unicode completos, no por unidades UTF-16 |
| No | Objetos literales, Object en general | Se recorren con Object.keys/values/entries, que devuelven arrays |
// 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.
// 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.
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
}
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 conocido | Para qué sirve |
|---|---|
Symbol.iterator | Hace un objeto recorrible con for...of y con el operador de propagación |
Symbol.asyncIterator | Lo mismo para for await...of |
Symbol.toStringTag | Personaliza el resultado de Object.prototype.toString (útil en depuración) |
Symbol.toPrimitive | Controla la conversión a número o cadena: evita coerciones sorprendentes en tus tipos |
Symbol.hasInstance | Personaliza el comportamiento de instanceof |
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.
| Criterio | Object | Map | Array | Set |
|---|---|---|---|---|
| Tipo de clave | Cadena o símbolo (todo se convierte a cadena) | Cualquier valor, incluidos objetos y funciones | Índice numérico | Cualquier valor |
| Buscar un elemento | O(1) | O(1) | O(n) con includes/find | O(1) con has |
| Saber cuántos hay | Object.keys(o).length, O(n) | .size, O(1) | .length, O(1) | .size, O(1) |
| Orden al recorrer | Reglas especiales (ver 1.10.3) | Estricto orden de inserción | Posicional | Estricto orden de inserción |
| Claves heredadas | Sí: toString, constructor… pueden colisionar | No: no hereda nada | — | No |
Serializable con JSON | Sí, directamente | No: hay que convertir con Object.fromEntries | Sí | No: [...set] |
| Borrar una entrada | delete: degrada la forma oculta | .delete(): barato y previsto | splice: O(n) | .delete(): O(1) |
// 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'
// 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
| Aspecto | WeakMap / WeakSet |
|---|---|
| Claves admitidas | Solo objetos (y símbolos no registrados); nunca cadenas ni números |
| Operaciones | get, set, has, delete. No hay size ni forma de recorrerlo |
| Motivo de esa limitación | El contenido depende del recolector: exponerlo haría el programa no determinista |
| Casos de uso reales | Datos 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.
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)));
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écnica | Profundidad | Conserva… | Problemas |
|---|---|---|---|
{ ...obj } / Object.assign | Superficial | Valores propios enumerables | Comparte los anidados; pierde el prototipo y los getters (los evalúa) |
[...arr] / arr.slice() | Superficial | Orden y elementos | Los elementos siguen siendo las mismas referencias |
structuredClone(obj) | Profunda | Date, Map, Set, RegExp, ArrayBuffer, ciclos | No copia funciones, Symbol, prototipos de clase ni descriptores; lanza DataCloneError |
JSON.parse(JSON.stringify(obj)) | Profunda «de mentira» | Solo lo que sobrevive a JSON | Ver la lista de abajo: es una mala idea casi siempre |
| Constructor de copia propio | La que decidas | Todo lo que programes | Hay que mantenerlo; es la opción correcta para entidades |
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.
// 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
}
// 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.
| Aspecto | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| Sintaxis | require() / module.exports | import / export |
| Carga | Síncrona, en tiempo de ejecución | Estática, analizable antes de ejecutar |
| Tree shaking | No (las exportaciones son dinámicas) | Sí: el bundler elimina lo no usado |
| Dónde domina | Node clásico, muchas librerías de NestJS | Navegador, Angular, Node moderno |
| Importación dinámica | require() en cualquier punto | import() devuelve una promesa |
// 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';
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 especificador | Cómo se resuelve |
|---|---|
Relativo: ./tasks.service | Desde 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.js | Ruta del sistema de ficheros tal cual. Casi nunca se usa en código de aplicación |
Desnudo: @nestjs/common | Busca 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: #config | Mapa 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.
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
- Streams. Procesar datos por trozos sin cargarlos en memoria: descargas de ficheros grandes,
exportaciones CSV, subidas.
pipeline()gestiona errores y cierre correctamente. Buffer. Datos binarios. Aparece al manejar ficheros y criptografía.process.env. Configuración por entorno. Nunca leasprocess.envdisperso por el código: centralízalo en un módulo de configuración tipado (capítulo 9).AsyncLocalStorage. Contexto por petición sin pasar parámetros por todas partes. Es exactamente el mecanismo que usa elRequestContextde MikroORM (capítulo 14) y los logs con identificador de correlación (capítulo 13).worker_threadsycluster. Paralelismo real:cluster(o un orquestador como Kubernetes) para escalar por procesos;worker_threadspara CPU intensiva.- Cierre ordenado. Escuchar
SIGTERM, dejar de aceptar peticiones, terminar las en curso y cerrar la conexión a la base de datos. En Nest:app.enableShutdownHooks().
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.
// 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
}
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'),
);
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.
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'
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:
| Elemento | Para qué | Aviso |
|---|---|---|
process.env | Configuración por entorno | Todo son cadenas: process.env.PORT nunca es un número. Léelo y valídalo una sola vez al arrancar |
process.argv | Argumentos de la línea de órdenes | Los dos primeros son el binario y el script; usa node:util parseArgs para lo demás |
process.exit(code) | Terminar de inmediato | Corta la E/S pendiente: no lo llames sin haber cerrado antes lo que tengas abierto |
process.on('SIGTERM') | Señal de apagado del orquestador | Es la base del despliegue sin cortes |
process.on('unhandledRejection') | Última red de seguridad | Regístralo para registrar el error y terminar de forma controlada, no para ignorarlo |
process.memoryUsage() | Diagnóstico | heapUsed y rss son las dos cifras que hay que vigilar |
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
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
| Herramienta | Qué es | Úsala para | No la uses para |
|---|---|---|---|
cluster | Varios procesos que comparten el puerto de escucha | Aprovechar todos los núcleos con un servidor HTTP | Nada, si ya despliegas varias réplicas: duplicaría el mecanismo |
worker_threads | Hilos en el mismo proceso, cada uno con su V8 | Cálculo intensivo: imágenes, criptografía, ficheros grandes | E/S: no aporta nada, ya es no bloqueante |
child_process | Lanzar otro programa (spawn) u otro script (fork) | Herramientas externas: ffmpeg, pg_dump | Trabajo pequeño y frecuente: domina el coste de arrancar |
| Cola de trabajos | Un servicio aparte que consume tareas (capítulo 11) | Todo lo que pueda esperar: correos, informes, sincronizaciones | Lo 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.
# 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 habitual | Síntoma | Solución |
|---|---|---|
Caché en un Map sin expiración | Un Map enorme retenido desde un módulo | Límite de tamaño y expiración (LRU), o caché externa |
| Suscripciones y listeners no liberados | Miles de closures reteniendo componentes | takeUntilDestroyed, removeListener, { once: true } |
| Acumular resultados «para el informe» | Un array que crece de forma monótona | Procesar por lotes con streams o generadores asíncronos |
| Identity Map del ORM en un proceso largo | Miles de entidades vivas fuera de una petición | em.clear() o un contexto nuevo por lote (capítulo 14) |
| Closures que capturan de más | Contextos con objetos enormes retenidos | Extraer 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:
- Superconjunto: todo JavaScript válido es TypeScript válido. Se puede adoptar de forma gradual.
- Estático: los errores se detectan al compilar, no al ejecutar.
- Estructural (duck typing): dos tipos son compatibles si tienen la misma forma, no si comparten nombre o herencia. Es distinto de Java o C#, que son nominales.
- Se borra (type erasure): en el JavaScript generado no queda ningún tipo. No puedes hacer
if (x instanceof MiInterfaz). Esta es la razón de fondo por la que Nest necesitareflect-metadatay por la que no puedes inyectar una interfaz.
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
| Tipo | Significado | Puedes… | Cuándo usarlo |
|---|---|---|---|
any | Desactiva el chequeo | Todo (y romperlo todo) | Casi nunca. Solo en migraciones temporales |
unknown | «Algo, pero no sé qué» | Nada hasta estrecharlo | Entrada externa: JSON, catch, librerías sin tipos |
never | No existe ningún valor | Nada | Funciones que siempre lanzan; comprobación de exhaustividad |
void | Sin valor de retorno útil | Ignorar el retorno | Funciones de efecto secundario |
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
// 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).
// 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
// 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'
// 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 type | Qué hace | Ejemplo |
|---|---|---|
Partial<T> | Todas las propiedades opcionales | DTO de actualización |
Required<T> | Todas obligatorias | Configuración ya resuelta |
Readonly<T> | Todas de solo lectura | Estado inmutable |
Pick<T,K> | Selecciona propiedades | Proyección de una entidad |
Omit<T,K> | Excluye propiedades | DTO de creación sin id |
Record<K,V> | Diccionario tipado | Mapa de traducciones |
ReturnType<F> | Tipo devuelto por una función | Derivar tipos de factorías |
Parameters<F> | Tupla de argumentos | Envolver funciones |
Awaited<P> | Desenvuelve promesas | Tipo de un await |
NonNullable<T> | Quita null y undefined | Tras validar |
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
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
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.
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.
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 cree | La realidad | Qué 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 JSON | Validar 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 sitio | Un 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 | undefined | Un 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 tipo | Migraciones y restricciones en el esquema (capítulos 17 y 19) |
«arr[0] siempre existe» | Sin noUncheckedIndexedAccess, el compilador miente sobre los índices | Activar 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 abajo | unknown 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 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.
// ---------- 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
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.
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) {}
}
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 {}
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
{
"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.
| Herramienta | Escrita en | ¿Comprueba tipos? | Papel habitual |
|---|---|---|---|
tsc | TypeScript | Sí, es la única que lo hace de verdad | Verificación en el editor y en integración continua; compilación en proyectos pequeños |
| esbuild | Go | No: borra los tipos sin mirarlos | Empaquetado y transpilación en Angular y Vite; construcciones de producción |
| SWC | Rust | No | Transpilación en NestJS (nest build --builder swc) y en Jest para acelerar los tests |
| Babel | JavaScript | No | Transformaciones 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.
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.
# 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
| Error | Causa real | Solució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 object | Object.keys/entries sobre un valor que no llegó | Comprobar antes, o Object.keys(x ?? {}) |
Un [object Object] en un log o en una URL | Se ha concatenado un objeto con una cadena | Logger estructurado o JSON.stringify; nunca concatenar objetos |
| Totales con céntimos que no cuadran | Aritmé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 solos | Un BIGINT supera Number.MAX_SAFE_INTEGER | Transportarlos como cadena; bigint en el código si hace falta operar |
Cannot find module '@app/...' solo en producción | Los paths de tsconfig no existen en tiempo de ejecución | Empaquetar, usar tsconfig-paths o los imports del package.json |
ERR_REQUIRE_ESM · Cannot use import statement outside a module | Mezcla de CommonJS y ESM | Alinear type del package.json con module de tsconfig; importación dinámica como último recurso |
JavaScript heap out of memory | Lote demasiado grande o fuga por retención | Streams o generadores asíncronos, em.clear(), instantáneas del montículo |
MaxListenersExceededWarning | Se registra un listener en cada petición | Registrarlo una sola vez al arrancar; { once: true } cuando proceda |
| Latencia alta con la CPU casi ociosa | El event loop está bloqueado por trabajo síncrono | Medir con monitorEventLoopDelay; mover el cálculo a un worker o a una cola |
Reflect.getMetadata is not a function | Falta import 'reflect-metadata' o falta emitDecoratorMetadata | Importarlo una única vez en el punto de entrada y revisar el tsconfig.json |
1.19 Buenas y malas prácticas
Haz esto
strict: truedesde el minuto uno. Añadirlo después a un proyecto grande cuesta semanas.- Prefiere
unknownaanyen 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, nodata2. - Async por defecto en E/S; nada de variantes
*Syncen 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
anypor comodidad. Cadaanyes un agujero por el que se cuelan errores en producción.aspara «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.logcomo 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áticox == 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 yPromise.allsobre 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ón | Permite actualizar a… | Riesgo |
|---|---|---|
1.2.3 | Exactamente esa versión | Ninguno; requiere mantenimiento manual |
~1.2.3 | Parches: 1.2.x | Bajo |
^1.2.3 | Menores y parches: 1.x.x | Medio (por defecto en npm) |
* o latest | Cualquier cosa | Alto: no lo uses |
npm cien CI y en Docker, nonpm install: instala exactamente lo delpackage-lock.json, es reproducible y más rápido.- Sube siempre el lockfile al repositorio.
dependenciesvsdevDependencies: lo que se necesita en runtime frente a lo que solo se usa para construir o testear. Afecta directamente al tamaño de la imagen Docker.peerDependencies: lo que una librería espera que aporte el proyecto anfitrión (típico en paquetes de Angular).- Auditoría periódica:
npm audit,npm outdatedy actualizaciones planificadas, no un salto de tres versiones mayores el día antes de una entrega.
1.21 Preguntas frecuentes
¿TypeScript hace mi aplicación más lenta?
Si los tipos se borran, ¿de qué me sirven en producción?
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?
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?
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?
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?
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?
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?
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?
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?
Si class existe, ¿para qué necesito saber de prototipos?
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?
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?
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?
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
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).
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.
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).
thisdepende 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;
emitDecoratorMetadataes lo que permite la inyección de dependencias por tipo, y por eso no se pueden inyectar interfaces sin un token. strict: trueno 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:
SetyMapresuelven 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
- TypeScript Handbook — la referencia oficial; los capítulos de genéricos y tipos avanzados son imprescindibles.
- MDN · JavaScript — documentación en español de cada método y concepto del lenguaje.
- Node.js · Event loop, timers y nextTick — explicación oficial de las fases de libuv.
- Type Challenges — ejercicios de tipos, de fáciles a extremos; el mejor gimnasio para el sistema de tipos.
- Blog de V8 — artículos sobre optimización y funcionamiento interno del motor.