Llevas usando for...of desde el Módulo 2 sin preguntarte cómo funciona. Recorre arrays, pero también strings, Map y Set; se puede interrumpir con break; y funciona igual con estructuras que no tienen índices. Nada de eso es casualidad: detrás hay un protocolo, un contrato que cualquier objeto puede cumplir para declararse recorrible. Entenderlo tiene dos recompensas inmediatas. La primera es que podrás escribir for (const tarea of tablero) sobre tu propia clase, en lugar de for (const tarea of tablero.tareas). La segunda es que te abre la puerta a los generadores, unas funciones que se pausan y se reanudan, producen valores solo cuando se los piden y permiten secuencias infinitas sin ocupar memoria. En esta lección, la última del módulo, verás ambos protocolos por dentro, harás iterable el Tablero, reescribirás el generador de identificadores de 03-04, recorrerás el árbol de subtareas de 03-07 de forma perezosa y leerás el backlog por páginas con iteradores asíncronos.

Contenido

  1. Qué hace for...of por dentro
  2. El protocolo iterador: next() y { value, done }
  3. Recorrer un array a mano con su iterador
  4. El protocolo iterable: Symbol.iterator
  5. Hacer iterable el Tablero
  6. Los iterables integrados y quién los consume
  7. Generadores: function* y yield
  8. Ejecución perezosa y secuencias infinitas
  9. yield*: delegar en otro iterable
  10. return y throw en un generador
  11. Iteradores asíncronos y for await...of
  12. Cuándo usar generadores y cuándo no
  13. Errores Comunes y Consejos
  14. Ejercicios
  15. Conclusión

  1. Qué hace for...of por dentro

Cuando escribes esto:

for (const tarea of backlog) {
  console.log(tarea.titulo);
}

el motor no hace lo que quizá imaginabas —recorrer índices de 0 a length - 1—. Hace algo más general:

  1. Pide al objeto su iterador, llamando a su método [Symbol.iterator]().
  2. Llama a next() sobre ese iterador.
  3. Si el resultado tiene done: false, asigna su value a la variable del bucle y ejecuta el cuerpo.
  4. Repite el paso 2 hasta obtener done: true.
flowchart TD
    A["for (const x of coleccion)"] --> B["coleccion[Symbol.iterator]()<br/>→ devuelve un iterador"]
    B --> C["iterador.next()"]
    C --> D{"¿done?"}
    D -->|"false"| E["x = value<br/>ejecutar el cuerpo"]
    E --> C
    D -->|"true"| F["fin del bucle"]

Que el bucle no sepa nada de índices es justo lo que le permite recorrer un Set, un Map o un string con la misma sintaxis. Y también explica por qué for...of no funciona sobre un objeto literal: un objeto plano no tiene [Symbol.iterator], así que no cumple el contrato.

const tarea = { id: 1, titulo: 'Rediseñar la sala polivalente' };
// for (const x of tarea) { }
// ✗ TypeError: tarea is not iterable

Ahí está la explicación de aquella distinción de 04-01 y 04-04 entre for...in (recorre claves de cualquier objeto) y for...of (recorre valores de un iterable). Son dos mecanismos completamente distintos, no dos variantes del mismo.

  1. El protocolo iterador: next() y { value, done }

Hay dos contratos, y conviene no mezclarlos.

Protocolo Qué exige Quién lo cumple
Iterable Un método [Symbol.iterator]() que devuelve un iterador Array, String, Map, Set, tu Tablero
Iterador Un método next() que devuelve { value, done } El objeto que produce [Symbol.iterator]()

Un iterador es un objeto con un método next() que, en cada llamada, devuelve un objeto con dos propiedades:

  • value: el elemento actual.
  • done: false si quedan elementos, true cuando se ha terminado.

Escribamos uno a mano, sin ninguna ayuda del lenguaje:

'use strict';

/** Iterador manual sobre las tareas abiertas de un array. */
function crearIteradorDeAbiertas(tareas) {
  let indice = 0;

  return {
    next() {
      // avanzar hasta la siguiente abierta
      while (indice < tareas.length && tareas[indice].estado === 'hecha') {
        indice++;
      }
      if (indice >= tareas.length) {
        return { value: undefined, done: true };
      }
      const tarea = tareas[indice];
      indice++;
      return { value: tarea, done: false };
    }
  };
}

const it = crearIteradorDeAbiertas(datosBacklog);

console.log(it.next());   // { value: { id: 1, titulo: 'Rediseñar…' }, done: false }
console.log(it.next());   // { value: { id: 2, … }, done: false }

Fíjate en dos cosas. Ese indice es una variable capturada por un closure (03-04): el iterador recuerda dónde se quedó entre llamadas, sin exponer esa variable a nadie. Y el iterador es de un solo uso: cuando llega a done: true, se ha agotado, y hay que crear otro para volver a recorrer.

  1. Recorrer un array a mano con su iterador

Los arrays ya traen el protocolo implementado. Se puede usar directamente, saltándose el for...of:

'use strict';

const etiquetas = ['espacio', 'diseño', 'carpintería'];
const it = etiquetas[Symbol.iterator]();

console.log(it.next());   // { value: 'espacio',     done: false }
console.log(it.next());   // { value: 'diseño',      done: false }
console.log(it.next());   // { value: 'carpintería', done: false }
console.log(it.next());   // { value: undefined,     done: true  }
console.log(it.next());   // { value: undefined,     done: true  }  ← agotado para siempre

Y este es literalmente el for...of desmontado, escrito con un while:

const iterador = etiquetas[Symbol.iterator]();
let resultado = iterador.next();

while (!resultado.done) {
  const etiqueta = resultado.value;
  console.log(etiqueta);
  resultado = iterador.next();
}

Nadie escribe así en el día a día —para eso está for...of—, pero verlo una vez fija el modelo mental. Y explica de paso un detalle importante: como el bucle solo pide next() cuando necesita el siguiente valor, un break simplemente deja de pedir. De ahí que for...of se pueda interrumpir y forEach no.

  1. El protocolo iterable: Symbol.iterator

Symbol.iterator es un símbolo bien conocido: un valor único que el lenguaje usa como nombre de propiedad para este contrato. Se escribe entre corchetes porque es una clave computada, la sintaxis que aprendiste en 04-01.

'use strict';

const rangoDeIds = {
  desde: 1,
  hasta: 6,

  [Symbol.iterator]() {
    let actual = this.desde;
    const fin = this.hasta;

    return {
      next() {
        if (actual > fin) return { value: undefined, done: true };
        return { value: actual++, done: false };
      }
    };
  }
};

for (const id of rangoDeIds) console.log(id);      // 1 2 3 4 5 6
console.log([...rangoDeIds]);                       // [ 1, 2, 3, 4, 5, 6 ]

Con un solo método ese objeto pasa a funcionar con for...of, con el spread de 04-07, con la desestructuración de 04-06 y con Array.from. Esa es la potencia de programar contra un protocolo: implementas el contrato una vez y ganas toda la sintaxis que lo consume.

¿Por qué un símbolo y no simplemente el nombre 'iterator'? Para evitar colisiones. Un objeto puede tener una propiedad iterator con cualquier significado sin interferir con el protocolo, porque Symbol.iterator es un valor único e irrepetible que nadie puede reproducir por accidente.

  1. Hacer iterable el Tablero

Con eso, la mejora que anunciaba la introducción. Hasta ahora escribías:

for (const tarea of tablero.tareas) { … }        // hay que conocer la propiedad interna

Añadiendo el protocolo, el tablero se recorre a sí mismo:

// js/modelo/tablero.js
export class Tablero {
  #tareas = [];

  // …todo lo del 05-04…

  /** Hace que el tablero sea iterable: for (const tarea of tablero). */
  [Symbol.iterator]() {
    return this.#tareas[Symbol.iterator]();       // delegamos en el array interno
  }
}

Una sola línea, porque el array ya sabe iterarse. Y todo esto empieza a funcionar:

const tablero = new Tablero('Taller Nómada', crearBacklog());

for (const tarea of tablero) {
  console.log(tarea.descripcion(HOY));
}

const todas = [...tablero];                            // spread
const [primera, segunda] = tablero;                    // desestructuración
const titulos = Array.from(tablero, (t) => t.titulo);  // Array.from con transformación

console.log(todas.length);      // 6
console.log(primera.titulo);    // 'Rediseñar la sala polivalente'
console.log(titulos.at(-1));    // 'Presupuesto de la carpintería'

Fíjate en lo que esto significa para la encapsulación de 05-03: el consumidor recorre las tareas sin que le expongamos el array interno. De hecho, #tareas sigue siendo privado, y el getter tareas que devolvía una copia ya ni siquiera hace falta para recorrer.

Se pueden ofrecer además varias formas de recorrido como métodos que devuelven iterables:

export class Tablero {
  // …

  /** Recorrido por defecto: todas las tareas, en orden de inserción. */
  [Symbol.iterator]() {
    return this.#tareas[Symbol.iterator]();
  }

  /** Solo las abiertas. Devuelve un iterable, no un array. */
  abiertasIterables() {
    const tareas = this.#tareas;
    return {
      [Symbol.iterator]() {
        let i = 0;
        return {
          next() {
            while (i < tareas.length && !tareas[i].abierta) i++;
            if (i >= tareas.length) return { done: true, value: undefined };
            return { done: false, value: tareas[i++] };
          }
        };
      }
    };
  }
}

for (const tarea of tablero.abiertasIterables()) console.log(tarea.id);   // 1 2 3 5 6

Ese abiertasIterables funciona, pero mira cuánta ceremonia: un objeto con [Symbol.iterator] que devuelve otro objeto con next que gestiona un índice a mano. Es incómodo de escribir y fácil de equivocar. En el apartado 7 lo reescribirás en tres líneas con un generador.

  1. Los iterables integrados y quién los consume

Estos son los iterables que trae el lenguaje o el navegador:

Iterable Qué produce al recorrerlo
Array Sus elementos, en orden
String Sus caracteres, respetando los emojis y caracteres compuestos
Map Pares [clave, valor]
Set Sus valores, sin duplicados
arguments Los argumentos de una función (objeto tipo array, no un array)
NodeList (Módulo 6) Los nodos del DOM devueltos por querySelectorAll
TypedArray Sus números
El resultado de entries(), keys(), values() Sus elementos

Un detalle que sorprende con los strings:

const texto = 'añil';
console.log([...texto]);          // [ 'a', 'ñ', 'i', 'l' ]
console.log('👩‍🎨'.length);          // 5  ← unidades de código
console.log([...'👩‍🎨'].length);     // 3  ← puntos de código

El iterador de String recorre puntos de código, no unidades de 16 bits, así que trata mejor los caracteres no latinos que el índice numérico. Es una razón práctica para preferir for...of sobre un for clásico al recorrer texto.

Y estas son las operaciones que consumen un iterable —todas funcionan con el Tablero desde que le pusimos el protocolo—:

Operación Ejemplo
for...of for (const t of tablero) …
Spread en array [...tablero]
Spread en argumentos Math.max(...horas)
Desestructuración de arrays const [primera] = tablero;
Array.from Array.from(tablero, (t) => t.titulo)
Constructores de Set y Map new Set(tablero)
Promise.all y compañía Promise.all(promesas)
yield* apartado 9

Un aviso importante: Object.keys, for...in, JSON.stringify y map/filter/reduce no usan este protocolo. Los métodos de array son métodos de Array.prototype (05-01) y solo existen en arrays. Por eso, para aplicar filter a un iterable cualquiera, primero hay que materializarlo:

const abiertas = [...tablero].filter((t) => t.abierta);      // ✓
// tablero.filter(...)  → solo si la clase define ese método

  1. Generadores: function* y yield

Escribir iteradores a mano es tedioso. Los generadores son funciones que los construyen automáticamente.

Se declaran con function* y producen valores con yield:

'use strict';

function* contarHasta(n) {
  for (let i = 1; i <= n; i++) {
    yield i;               // "entrega este valor y pausa aquí"
  }
}

const gen = contarHasta(3);

console.log(gen.next());   // { value: 1, done: false }
console.log(gen.next());   // { value: 2, done: false }
console.log(gen.next());   // { value: 3, done: false }
console.log(gen.next());   // { value: undefined, done: true }

for (const n of contarHasta(3)) console.log(n);   // 1 2 3

Tres cosas ocurren aquí que no ocurren con una función normal:

  1. Llamar al generador no ejecuta su cuerpo. contarHasta(3) devuelve un objeto generador sin ejecutar ni una línea del for.
  2. Cada next() ejecuta hasta el siguiente yield y ahí se pausa, conservando todas las variables locales.
  3. El objeto generador es a la vez iterador e iterable: tiene next() y también [Symbol.iterator]() devolviéndose a sí mismo. Por eso funciona directamente en un for...of.

Esa pausa es la característica única: una función normal, una vez empieza, corre hasta el return. Un generador puede detenerse a mitad y continuar después, exactamente donde lo dejó.

Ahora reescribe el abiertasIterables del apartado 5:

export class Tablero {
  // …

  /** Solo las tareas abiertas, de forma perezosa. */
  *abiertas() {
    for (const tarea of this.#tareas) {
      if (tarea.abierta) yield tarea;
    }
  }

  /** Tareas de un responsable. */
  *de(responsable) {
    for (const tarea of this.#tareas) {
      if (tarea.responsable === responsable) yield tarea;
    }
  }

  /** Tareas vencidas a una fecha dada (R10). */
  *vencidas(hoy) {
    for (const tarea of this.#tareas) {
      if (tarea.estaVencida(hoy)) yield tarea;
    }
  }
}

for (const t of tablero.abiertas()) console.log(t.id);        // 1 2 3 5 6
for (const t of tablero.de('Iván')) console.log(t.titulo);    // las 3 de Iván
console.log([...tablero.vencidas(HOY)].length);               // 1

De veinte líneas de ceremonia a tres. Fíjate en la sintaxis *abiertas(): es un método generador dentro de una clase, y también existe en objetos literales (*metodo() { … }) y como el propio *[Symbol.iterator]().

De hecho, el iterable del tablero se puede escribir así:

*[Symbol.iterator]() {
  yield* this.#tareas;         // el yield* del apartado 9
}

  1. Ejecución perezosa y secuencias infinitas

Que un generador solo trabaje cuando se le pide se llama evaluación perezosa (lazy), y tiene dos consecuencias enormes.

Consecuencia 1: no se calcula lo que no se usa.

'use strict';

function* tareasConTraza(tareas) {
  for (const t of tareas) {
    console.log(`  · evaluando ${t.id}`);
    yield t;
  }
}

// Buscamos la primera de más de 10 h
for (const t of tareasConTraza(datosBacklog)) {
  if (t.horasEstimadas > 10) {
    console.log(`Encontrada: ${t.titulo}`);
    break;                                   // ← el generador no continúa
  }
}
//   · evaluando 1
// Encontrada: Rediseñar la sala polivalente

Solo se evaluó una tarea. Compáralo con datosBacklog.filter(t => t.horasEstimadas > 10)[0], que recorre las seis y construye un array intermedio. Con seis elementos da igual; con doscientas mil, la diferencia es abismal.

Consecuencia 2: se pueden representar secuencias infinitas. Un array infinito es imposible; un generador infinito es trivial, porque los valores no existen hasta que se piden.

Retomemos el generador de identificadores de 03-04, aquel closure que devolvía una función:

// ── Versión de 03-04, con closure ─────────────────────────────────
function crearGeneradorDeIds(inicial = 0) {
  let ultimoId = inicial;
  return function siguienteId() {
    ultimoId = ultimoId + 1;
    return ultimoId;
  };
}

// ── Versión con generador ─────────────────────────────────────────
function* generadorDeIds(inicial = 0) {
  let id = inicial;
  while (true) {              // ✓ bucle infinito perfectamente seguro
    id += 1;
    yield id;
  }
}

const ids = generadorDeIds(6);      // el backlog canónico llega hasta el 6

console.log(ids.next().value);      // 7
console.log(ids.next().value);      // 8
console.log(ids.next().value);      // 9

Ese while (true) no cuelga nada porque el generador está parado en el yield la mayor parte del tiempo: solo avanza una vuelta por cada next(). Compara los dos enfoques:

Closure (03-04) Generador
Cómo se pide el siguiente siguienteId() ids.next().value
Estado interno Variable capturada Variables locales pausadas
¿Funciona con for...of? No
¿Se combina con take, yield*…? No
Legibilidad de la secuencia Se infiere del cuerpo Explícita: se lee como un bucle

Ojo con una trampa evidente: nunca hagas spread de un generador infinito.

// const todos = [...generadorDeIds()];    // ⚠ cuelga el programa para siempre

Para consumir una parte, hace falta una función que tome los primeros N —y que, como cualquier otra combinación de iterables, también es un generador:

/** Toma los n primeros elementos de cualquier iterable. */
function* tomar(iterable, n) {
  let contados = 0;
  for (const valor of iterable) {
    if (contados >= n) return;
    yield valor;
    contados++;
  }
}

console.log([...tomar(generadorDeIds(6), 5)]);   // [ 7, 8, 9, 10, 11 ]

Y ya que estamos, map y filter perezosos, que funcionan sobre secuencias infinitas al contrario que sus homónimos de array:

function* mapear(iterable, transformar) {
  for (const v of iterable) yield transformar(v);
}

function* filtrar(iterable, predicado) {
  for (const v of iterable) if (predicado(v)) yield v;
}

const idsPares = filtrar(generadorDeIds(0), (n) => n % 2 === 0);
const etiquetados = mapear(idsPares, (n) => `T-${String(n).padStart(3, '0')}`);

console.log([...tomar(etiquetados, 4)]);   // [ 'T-002', 'T-004', 'T-006', 'T-008' ]

Esa cadena procesa los valores de uno en uno a través de las tres funciones, sin construir ni un solo array intermedio. Es la composición de 03-06 aplicada a flujos de datos.

  1. yield*: delegar en otro iterable

yield* (con asterisco) entrega todos los valores de otro iterable, uno a uno, como si estuvieran escritos ahí.

function* primeros() { yield 1; yield 2; }
function* segundos() { yield 3; yield 4; }

function* todos() {
  yield* primeros();
  yield* segundos();
  yield 5;
}

console.log([...todos()]);        // [ 1, 2, 3, 4, 5 ]

Sin el asterisco, yield primeros() entregaría el objeto generador como un único valor, no sus elementos.

Su aplicación estrella es la recursión sobre estructuras anidadas, y aquí retomamos el árbol de subtareas de 03-07: «Rediseñar la sala polivalente» con sus 12 h repartidas y 7 h abiertas.

'use strict';

const tareaRediseno = {
  id: 1, titulo: 'Rediseñar la sala polivalente', responsable: 'Iván',
  estado: 'en-curso', horasEstimadas: 0,
  subtareas: [
    { id: 11, titulo: 'Medir y levantar el plano', responsable: 'Iván',
      estado: 'hecha', horasEstimadas: 3, subtareas: [] },
    { id: 12, titulo: 'Elegir el mobiliario', responsable: 'Marta',
      estado: 'en-curso', horasEstimadas: 0,
      subtareas: [
        { id: 121, titulo: 'Pedir presupuestos', responsable: 'Marta',
          estado: 'hecha', horasEstimadas: 2, subtareas: [] },
        { id: 122, titulo: 'Visitar a dos proveedores', responsable: 'Marta',
          estado: 'pendiente', horasEstimadas: 2, subtareas: [] }
      ] },
    { id: 13, titulo: 'Pintar y montar', responsable: 'Iván',
      estado: 'pendiente', horasEstimadas: 5, subtareas: [] }
  ]
};

/** Recorre un árbol de tareas en profundidad, de forma perezosa. */
function* recorrerArbol(tarea, profundidad = 0) {
  yield { tarea, profundidad };
  for (const sub of tarea.subtareas ?? []) {
    yield* recorrerArbol(sub, profundidad + 1);      // ← delegación recursiva
  }
}

for (const { tarea, profundidad } of recorrerArbol(tareaRediseno)) {
  console.log(`${'  '.repeat(profundidad)}[${tarea.id}] ${tarea.titulo} · ${tarea.horasEstimadas} h`);
}
// [1] Rediseñar la sala polivalente · 0 h
//   [11] Medir y levantar el plano · 3 h
//   [12] Elegir el mobiliario · 0 h
//     [121] Pedir presupuestos · 2 h
//     [122] Visitar a dos proveedores · 2 h
//   [13] Pintar y montar · 5 h

Compáralo con el aplanarTareas de 03-07, que construía un array completo con concat. Aquí no se crea ningún array: los nodos se van entregando conforme se visitan. Y sobre ese recorrido perezoso todo lo demás sale gratis:

const nodos = [...recorrerArbol(tareaRediseno)].map((n) => n.tarea);

console.log(nodos.reduce((s, t) => s + t.horasEstimadas, 0));                       // 12
console.log(nodos.filter((t) => t.estado !== 'hecha').reduce((s, t) => s + t.horasEstimadas, 0));   // 7

// Buscar sin recorrer el árbol entero: se detiene en cuanto encuentra
function buscarPorId(raiz, id) {
  for (const { tarea } of recorrerArbol(raiz)) {
    if (tarea.id === id) return tarea;
  }
  return null;
}
console.log(buscarPorId(tareaRediseno, 121).titulo);   // 'Pedir presupuestos'

Los 12 h totales y 7 h abiertas del árbol canónico, con un recorrido que además se puede interrumpir a mitad. Esa es la ventaja concreta de la pereza sobre la recursión que devuelve arrays.

  1. return y throw en un generador

Un objeto generador tiene dos métodos más, además de next().

return(valor) termina el generador anticipadamente:

function* contar() {
  try {
    yield 1;
    yield 2;
    yield 3;
  } finally {
    console.log('limpieza del generador');     // se ejecuta igualmente
  }
}

const g = contar();
console.log(g.next());        // { value: 1, done: false }
console.log(g.return(99));    // limpieza del generador
                               // { value: 99, done: true }
console.log(g.next());        // { value: undefined, done: true }  ← agotado

Ese finally es la razón principal de que este método exista: permite liberar recursos —cerrar un fichero, una conexión— aunque el consumidor abandone a mitad. Y lo importante: for...of llama a return() automáticamente cuando sales con break, return o una excepción. Es decir, la limpieza está garantizada:

for (const n of contar()) {
  if (n === 2) break;         // provoca la llamada a return() → se imprime la limpieza
}

throw(error) inyecta una excepción en el punto donde el generador está pausado:

function* procesar() {
  try {
    yield 'listo';
    yield 'nunca llego';
  } catch (error) {
    console.log('el generador capturó:', error.message);
    yield 'recuperado';
  }
}

const p = procesar();
console.log(p.next().value);                            // 'listo'
console.log(p.throw(new Error('fallo externo')).value); // el generador capturó: fallo externo
                                                         // 'recuperado'

Se usa poco en código de aplicación, pero conviene conocerlo: junto con la posibilidad de pasar un valor a next(v) —que llega como resultado del yield donde el generador estaba pausado—, forma un canal de comunicación bidireccional. Sobre ese mecanismo se construyeron las primeras implementaciones de async/await antes de que fuera sintaxis del lenguaje.

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

Falta el último caso: recorrer algo cuyos elementos llegan con el tiempo, como las páginas de resultados de un servidor. Para eso existen los protocolos asíncronos, que son la versión con promesas de los dos anteriores.

Síncrono Asíncrono
Método del iterable [Symbol.iterator]() [Symbol.asyncIterator]()
Qué devuelve next() { value, done } Una promesa de { value, done }
Cómo se declara el generador function* async function*
Cómo se consume for...of for await...of

Aplicado a leer el backlog de Taller Nómada por páginas simuladas:

// js/datos/paginado.js
import { Tarea } from '../modelo/tarea.js';
import { datosBacklog } from './backlog.js';

const esperar = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

/**
 * Simula una API paginada. En el Módulo 7 esto será fetch con ?pagina=N (07-02).
 * @returns {Promise<{tareas: Object[], hayMas: boolean}>}
 */
async function leerPagina(numero, porPagina = 2) {
  await esperar(300);                                   // latencia simulada
  const inicio = (numero - 1) * porPagina;
  const trozo = datosBacklog.slice(inicio, inicio + porPagina);
  return { tareas: trozo, hayMas: inicio + porPagina < datosBacklog.length };
}

/** Generador asíncrono: entrega las tareas página a página, bajo demanda. */
export async function* leerBacklogPaginado(porPagina = 2) {
  let pagina = 1;
  let hayMas = true;

  while (hayMas) {
    console.log(`  ⇩ pidiendo la página ${pagina}…`);
    const respuesta = await leerPagina(pagina, porPagina);
    for (const datos of respuesta.tareas) {
      yield new Tarea(datos);                           // entrega tarea a tarea
    }
    hayMas = respuesta.hayMas;
    pagina += 1;
  }
}

Y su consumo, que oculta por completo la paginación:

import { leerBacklogPaginado } from './datos/paginado.js';

let horas = 0;
for await (const tarea of leerBacklogPaginado()) {
  console.log(tarea.descripcion(HOY));
  horas += tarea.horasEstimadas;
}
console.log(`Total: ${horas} h`);

//   ⇩ pidiendo la página 1…
// ▸ [1] Rediseñar la sala polivalente · Iván · 12 h
// ○ [2] Cartelería del taller de serigrafía · Marta · 6 h
//   ⇩ pidiendo la página 2…
// ○ [3] Actualizar la web de reservas · Lucía · 14 h
// ✓ [4] Inventario de tintas de serigrafía · Marta · 3 h
//   ⇩ pidiendo la página 3…
// ▸ [5] Guía de encuadernación para residentes · Iván · 8 h
// ○ [6] Presupuesto de la carpintería · Iván · 5 h ⚠ VENCIDA
// Total: 48 h

Las 48 h canónicas, obtenidas en tres peticiones que quien consume el bucle no ve. Tres méritos de este diseño:

  1. La paginación queda encapsulada. El consumidor escribe un bucle normal; el generador se encarga de pedir la página siguiente cuando hace falta.
  2. Es perezoso de verdad. Si el consumidor hace break en la tercera tarea, las páginas 2 y 3 nunca se piden. Con una función que devolviera todo el array, se habrían pedido igualmente.
  3. La memoria es constante. Solo hay una página en memoria a la vez, aunque el backlog tuviera un millón de tareas.
// Prueba de la pereza: solo se pide la primera página
for await (const tarea of leerBacklogPaginado()) {
  if (tarea.horasEstimadas > 10) { console.log('Encontrada:', tarea.titulo); break; }
}
//   ⇩ pidiendo la página 1…
// Encontrada: Rediseñar la sala polivalente

Un apunte de 05-06: for await...of procesa los elementos secuencialmente, esperando cada uno. Si lo que quieres es lanzar todo en paralelo, sigue siendo Promise.all. Aquí la secuencialidad es deseada: no se puede pedir la página 2 sin saber si existe.

  1. Cuándo usar generadores y cuándo no

Los generadores son elegantes y por eso se abusa de ellos. La regla honesta:

Usa un generador cuando… Usa un array o una función normal cuando…
La secuencia es infinita o de longitud desconocida Sabes cuántos elementos hay y caben en memoria
Producir cada elemento es caro y quizá no los necesites todos Producirlos es barato
Quieres poder interrumpir el recorrido a mitad Vas a recorrerlo entero siempre
Recorres una estructura recursiva (árboles) y quieres pereza Un map/filter normal resuelve el caso
Los datos llegan por trozos (páginas, flujos) Los tienes todos de golpe
Quieres encadenar transformaciones sin arrays intermedios La legibilidad de filter().map() es preferible

Y los inconvenientes reales, que hay que sopesar:

  • No hay length. No puedes saber cuántos elementos producirá sin consumirlo entero.
  • Un solo uso. Consumido un generador, se acabó; hay que crear otro. Un array se recorre las veces que quieras.
  • No tiene map, filter ni reduce. Hay que escribirlos (como en el apartado 8) o materializar con [...gen], lo que anula la pereza.
  • Depurar es más difícil. Poner un punto de interrupción dentro de un generador que se pausa y reanuda desconcierta al principio.
  • Con pocos elementos, no compensa. Para el backlog de seis tareas, filter es más claro y no hay ninguna ganancia. La pereza empieza a pagar con miles de elementos, con producción cara o con datos que llegan de fuera.

En Nómada Tareas los usamos donde de verdad aportan: [Symbol.iterator] en el Tablero por comodidad de la API, el recorrido perezoso del árbol de subtareas, y el generador asíncrono del backlog paginado.

Errores Comunes y Consejos

  • Olvidar el asterisco. function generador() con yield dentro es un SyntaxError. El asterisco puede ir pegado a function o al nombre: ambos valen.
  • Llamar al generador y esperar que se ejecute. contarHasta(3) no ejecuta nada; hay que consumirlo con next() o con for...of.
  • Confundir yield con yield*. Sin asterisco entregas el iterable entero como un valor; con asterisco entregas sus elementos.
  • Hacer spread de un generador infinito. [...generadorDeIds()] cuelga el programa. Usa una función tomar(n).
  • Reutilizar un generador agotado. Devuelve { done: true } para siempre y el bucle no da ni una vuelta. Si necesitas recorrer dos veces, crea dos generadores o materializa en un array.
  • Devolver el mismo iterador desde [Symbol.iterator](). Si guardas el iterador en una propiedad y lo devuelves siempre, el segundo for...of no recorrerá nada. [Symbol.iterator]() debe devolver un iterador nuevo en cada llamada.
  • Esperar que for...of funcione sobre un objeto plano. No es iterable. Usa Object.entries(obj), que sí lo es.
  • Usar for await...of fuera de una función async (o del nivel superior de un módulo). Es la misma regla que await.
  • Creer que for await...of paraleliza. Es secuencial por definición. Para paralelo, Promise.all (05-06).
  • Consejo: cuando dudes de si algo es iterable, pregúntaselo: typeof x?.[Symbol.iterator] === 'function'. Es más fiable que suponerlo.

Ejercicios

Ejercicio 1 — Iterable a mano. Escribe una clase Semana que reciba una fecha ISO de inicio y sea iterable, produciendo los siete días de esa semana en formato 'yyyy-mm-dd'. Impleméntala sin generadores, con [Symbol.iterator]() devolviendo un objeto con next(). Compruébala con for...of, con spread y con desestructuración, y verifica que se puede recorrer dos veces seguidas.

Ejercicio 2 — Generadores compuestos. Escribe tres generadores —tomar(iterable, n), saltar(iterable, n) y porLotes(iterable, tamano)— y úsalos para producir, a partir de un generadorDeIds(6) infinito, los identificadores del 11 al 20 agrupados en lotes de 3. Después aplícalos al backlog para obtener las tareas abiertas en lotes de 2.

Ejercicio 3 — Recorrido asíncrono con corte. Amplía leerBacklogPaginado para que acepte un objeto de opciones { porPagina, filtro } y solo entregue las tareas que cumplan el filtro. Escribe después primeraQueCumple(iterableAsincrono, predicado) que devuelva la primera coincidencia deteniendo el recorrido, y demuestra con la traza de consola que solo se pidió la página necesaria.

Soluciones

Ejercicio 1

'use strict';

class Semana {
  #inicio;

  constructor(fechaISO) {
    this.#inicio = fechaISO;
  }

  get inicio() { return this.#inicio; }

  [Symbol.iterator]() {
    const base = new Date(this.#inicio);
    let dia = 0;                                  // ← estado NUEVO en cada llamada

    return {
      next() {
        if (dia >= 7) return { value: undefined, done: true };
        const fecha = new Date(base);
        fecha.setDate(base.getDate() + dia);
        dia += 1;
        return { value: fecha.toISOString().slice(0, 10), done: false };
      }
    };
  }
}

const semana = new Semana('2026-09-14');          // la semana del HOY canónico

for (const dia of semana) console.log(dia);
// 2026-09-14 … 2026-09-20

console.log([...semana].length);                  // 7
const [lunes, martes] = semana;
console.log(lunes, martes);                       // 2026-09-14 2026-09-15

// Se recorre dos veces sin problema:
console.log([...semana][6], [...semana][0]);      // 2026-09-20 2026-09-14

El punto crítico del ejercicio está en las dos líneas que declaran base y dia dentro de [Symbol.iterator](). Si las hubieras puesto como campos de la clase, el primer recorrido dejaría dia en 7 y el segundo for...of no daría ni una vuelta: es el error de «devolver el mismo iterador» de los errores comunes. Cada llamada al método debe producir un iterador virgen, con su propio estado capturado por closure.

Como comparación, la versión con generador cabe en cuatro líneas y no tiene ese riesgo:

*[Symbol.iterator]() {
  const base = new Date(this.#inicio);
  for (let d = 0; d < 7; d++) {
    const fecha = new Date(base);
    fecha.setDate(base.getDate() + d);
    yield fecha.toISOString().slice(0, 10);
  }
}

Ejercicio 2

'use strict';

function* tomar(iterable, n) {
  if (n <= 0) return;
  let contados = 0;
  for (const v of iterable) {
    yield v;
    contados++;
    if (contados >= n) return;
  }
}

function* saltar(iterable, n) {
  let saltados = 0;
  for (const v of iterable) {
    if (saltados < n) { saltados++; continue; }
    yield v;
  }
}

function* porLotes(iterable, tamano) {
  let lote = [];
  for (const v of iterable) {
    lote.push(v);
    if (lote.length === tamano) {
      yield lote;
      lote = [];                    // array nuevo: no reutilizar el entregado
    }
  }
  if (lote.length > 0) yield lote;  // el último lote incompleto
}

// Ids del 11 al 20, en lotes de 3
const ids = generadorDeIds(6);                 // produce 7, 8, 9, …
const delOnceAlVeinte = tomar(saltar(ids, 4), 10);   // salta 7-10, toma 11-20

console.log([...porLotes(delOnceAlVeinte, 3)]);
// [ [ 11, 12, 13 ], [ 14, 15, 16 ], [ 17, 18, 19 ], [ 20 ] ]

// Tareas abiertas del backlog, en lotes de 2
const tablero = new Tablero('Taller Nómada', crearBacklog());
for (const lote of porLotes(tablero.abiertas(), 2)) {
  console.log(lote.map((t) => t.id).join(', '));
}
// 1, 2
// 3, 5
// 6

Cuatro observaciones. La composición tomar(saltar(ids, 4), 10) funciona porque todo generador es iterable, así que uno puede consumir a otro: es exactamente la idea de componer/pipe de 03-06, ahora sobre flujos. Nada se materializa hasta el [...] final, y el generador infinito nunca da problemas porque tomar deja de pedirle valores. El lote = [] tras cada yield es imprescindible: si reutilizaras el mismo array, todos los lotes entregados serían el mismo objeto y acabarían con el contenido del último —el mismo problema de referencias compartidas de 04-08—. Y el último if fuera del bucle entrega el lote incompleto, que es casi siempre lo que se quiere.

Ejercicio 3

'use strict';

/**
 * Lee el backlog paginado, entregando solo las tareas que pasan el filtro.
 * @param {Object}   [opciones]
 * @param {number}   [opciones.porPagina=2]
 * @param {Function} [opciones.filtro]  (tarea) => boolean
 */
export async function* leerBacklogPaginado({ porPagina = 2, filtro = () => true } = {}) {
  let pagina = 1;
  let hayMas = true;

  while (hayMas) {
    console.log(`  ⇩ pidiendo la página ${pagina}…`);
    const respuesta = await leerPagina(pagina, porPagina);
    for (const datos of respuesta.tareas) {
      const tarea = new Tarea(datos);
      if (filtro(tarea)) yield tarea;
    }
    hayMas = respuesta.hayMas;
    pagina += 1;
  }
}

/** Primera coincidencia de un iterable asíncrono; detiene el recorrido. */
async function primeraQueCumple(iterableAsincrono, predicado) {
  for await (const elemento of iterableAsincrono) {
    if (predicado(elemento)) return elemento;
  }
  return null;
}

// Caso 1 · la coincidencia está en la primera página
const primera = await primeraQueCumple(
  leerBacklogPaginado({ filtro: (t) => t.abierta }),
  (t) => t.horasEstimadas > 10
);
console.log('→', primera.titulo);
//   ⇩ pidiendo la página 1…
// → Rediseñar la sala polivalente          ← una sola petición

// Caso 2 · la coincidencia está en la última página
const vencida = await primeraQueCumple(
  leerBacklogPaginado({ filtro: (t) => t.abierta }),
  (t) => t.estaVencida(HOY)
);
console.log('→', vencida.titulo);
//   ⇩ pidiendo la página 1…
//   ⇩ pidiendo la página 2…
//   ⇩ pidiendo la página 3…
// → Presupuesto de la carpintería          ← tres peticiones, las justas

// Caso 3 · sin coincidencias: se agota el iterable
console.log(await primeraQueCumple(leerBacklogPaginado(), (t) => t.horasEstimadas > 100));
// null (tras las tres páginas)

La traza es la demostración del ejercicio: en el primer caso se pide una página, en el segundo tres, y en ningún momento se piden páginas de más. Lo consigue el return dentro del for await...of, que provoca que el bucle llame al return() del iterador asíncrono —el mismo mecanismo del apartado 10— y el generador se detenga limpiamente en su yield, sin llegar nunca a la siguiente iteración del while.

Compara esto con la alternativa ingenua: una función que descargue todas las páginas, construya un array de las 6 tareas y luego haga find. Con seis tareas es idéntico; con un backlog de diez mil repartido en cinco mil páginas, la diferencia entre una petición y cinco mil es la diferencia entre una aplicación usable y una inservible. Ese es exactamente el patrón que aplicarás en el Módulo 7 al paginar datos reales con fetch.

Conclusión

Has llegado al mecanismo que explica una sintaxis que usabas desde el Módulo 2. for...of no recorre índices: pide un iterador con [Symbol.iterator]() y lo consulta con next() hasta recibir { done: true }. Son dos contratos distintos —iterable, el que sabe producir un iterador; iterador, el que sabe entregar valores de uno en uno— y basta implementar el primero para ganar de golpe for...of, el spread, la desestructuración, Array.from y los constructores de Set y Map. Lo has comprobado escribiendo un iterador a mano, desmontando el for...of en un while, y añadiendo una línea a Tablero para poder escribir for (const tarea of tablero) sin exponer el array privado. También sabes por qué for...of se interrumpe con break y forEach no, por qué un objeto plano no es iterable, y por qué map, filter y Object.keys juegan en otra liga: son métodos de array, no consumidores del protocolo.

Sobre esa base has descubierto los generadores: funciones function* que se pausan en cada yield y se reanudan en cada next(), conservando sus variables locales. Llamarlas no ejecuta nada; el objeto generador que devuelven es a la vez iterador e iterable. Esa pereza tiene dos consecuencias que has explotado a fondo: no se calcula lo que no se pide —la búsqueda que evalúa una sola tarea y para— y las secuencias pueden ser infinitas, como el generadorDeIds con su while (true) perfectamente seguro que reescribe el closure de 03-04 ganando compatibilidad con for...of y con la composición. Con tomar, saltar, mapear, filtrar y porLotes has montado tuberías de datos sin un solo array intermedio, y con yield* has recorrido el árbol de subtareas de 03-07 de forma perezosa e interrumpible, recuperando sus 12 h totales y sus 7 h abiertas sin construir la lista aplanada. Conoces return() y throw() sobre un generador, y el detalle valioso de que for...of llama a return() automáticamente al salir con break, lo que garantiza la ejecución de cualquier finally de limpieza. Y has cerrado el círculo con los protocolos asíncronos: async function*, Symbol.asyncIterator y for await...of aplicados a un backlog paginado que oculta la paginación al consumidor, pide solo las páginas necesarias y mantiene la memoria constante. Sabes, por último, cuándo no usarlos: sin length, de un solo uso, sin métodos de array y sin ventaja alguna cuando los datos son pocos y baratos.

Con esto se cierra el Módulo 5, y merece la pena mirar atrás. Entraste con seis objetos literales que repetían sus métodos uno a uno y sales con un modelo completo: los prototipos que explican cómo JavaScript comparte comportamiento de verdad; las clases que lo escriben de forma legible, con extends, super y polimorfismo; la encapsulación con #estado, getters, setters y una API pública diseñada a conciencia, que hace imposible dejar una tarea en un estado ilegal; los módulos que reparten el proyecto en modelo/, datos/ y util/ con dependencias explícitas y sin ciclos; y el salto entero a la asincronía —callbacks, promesas, async/await, el bucle de eventos y las microtareas, iteradores asíncronos—, con cargarTablero() capaz de traer los datos con latencia, tiempo máximo, validación y reserva local. Los números canónicos de Taller Nómada siguen siendo los mismos que en el Módulo 1 —48 h totales, 45 abiertas, Iván con 25, Lucía con 14, Marta con 6, la carpintería vencida, esfuerzo ponderado 124—, pero la maquinaria que los produce ya no es un guion: es una aplicación.

Y aquí llega el cambio de escenario. Todo lo que has construido en cinco módulos vive en la consola. Marta, Iván y Lucía no van a abrir las herramientas de desarrollo para saber qué tienen que hacer hoy: necesitan ver el tablero en la pantalla, marcar una tarea como hecha con un clic, filtrar por responsable y dar de alta una nueva desde un formulario. El modelo está construido, protegido, modularizado y probado por consola; ha llegado el momento de que se vea. Eso significa aprender a hablar con la página: seleccionar elementos, crearlos, modificarlos y reaccionar a lo que hace el usuario —y, con lo que sabes ahora del bucle de eventos, entendiendo exactamente por qué un manejador lento congela la interfaz—. Es el Módulo 6: El Modelo de Objetos del Documento, que empieza con Introducción al DOM.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados