El protocolo de iteración
Muchas funcionalidades de JavaScript — for...of, el spread (...), el destructuring, Array.from, Promise.all — comparten un mismo mecanismo interno: el protocolo de iteración. Cuando lo entiendes, todas empiezan a parecer variaciones de la misma idea.
Un iterador es cualquier objeto que tenga un método next() que devuelva { value, done }:
Llama a next() varias veces. Cada llamada devuelve el siguiente valor y un flag done. Cuando done vale true, la secuencia se acabó. Ese es el protocolo entero: cuatro teclas y un booleano.
Iterable vs iterador en JavaScript
Hay un segundo concepto muy ligado a este. Un iterable es cualquier cosa que sabe producir un iterador, y lo hace a través de un método guardado bajo una clave especial: Symbol.iterator.
Los arrays son iterables. Si llamas a numbers[Symbol.iterator]() obtienes un iterador nuevo. Los strings, Map, Set y arguments también son iterables, y justo por eso for...of funciona con todos ellos.
La distinción es clave: el iterable es la colección; el iterador es el cursor que la recorre. De un mismo iterable puedes pedir tantos cursores independientes como necesites.
Por qué funciona for...of
for...of no es más que azúcar sintáctico sobre el protocolo de iteración de JavaScript. Por dentro llama a Symbol.iterator y luego invoca next() hasta que done vale true:
La propagación y la desestructuración hacen justo lo mismo: recorren un iterador hasta agotarlo:
Cualquier objeto que implemente Symbol.iterator puede aprovechar todas estas características sin esfuerzo.
Cómo crear un iterable personalizado en JavaScript
Vamos a construir un objeto range que vaya emitiendo números desde start hasta end:
Algunas cosas que conviene notar:
[Symbol.iterator]()usa un nombre de método computado. La clave es el símbolo en sí, no la cadena"Symbol.iterator".- Cada llamada a
[Symbol.iterator]()devuelve un iterador completamente nuevo con su propiocurrent. Por eso puedes recorrerrangedos veces sin que se "agote". - El iterador devuelto solo necesita
next(). Nada más.
Funciona, sí, pero es verboso. Hay una forma mucho mejor de hacerlo.
Llegan los generadores
Una función generadora se declara con function* (ojo al asterisco). En lugar de ejecutarse de principio a fin, puede pausarse en una expresión yield y retomar la ejecución más tarde. Al llamarla, el cuerpo no se ejecuta: te devuelve un objeto generador que es, a la vez, iterador e iterable:
Cada llamada a next() ejecuta el cuerpo hasta toparse con un yield, ahí se pausa y devuelve { value, done: false }. Cuando la función termina, recibes { value: undefined, done: true }.
Y como los generadores son iterables, encajan con todo lo que vimos en la sección anterior:
Reescribiendo range con un generador
Compara la versión larga de arriba con esta:
Eso es todo. El * delante de [Symbol.iterator] convierte al método en un generador. yield i reemplaza todo ese objeto iterador hecho a mano. Sin next, sin done, sin riesgo de desfase en los índices: solo un bucle normal con yield en lugar de push.
Para esto existen los generadores en JavaScript. Transforman el "escribe un iterador" en "escribe una función que haga yield".
yield vs return en JavaScript
yield pausa; return termina. Puedes hacer yield tantas veces como quieras, y el generador retoma la ejecución justo donde la dejó:
Un return dentro de un generador aparece como { value: "done", done: true } en la llamada que lo termina. Tanto for...of como el spread ignoran ese valor devuelto: solo consumen elementos donde done sea false. Así que no intentes colar un último valor en el bucle con return value, porque se lo va a saltar.
Secuencias perezosas e infinitas
Los generadores producen valores bajo demanda, uno a uno. Esto te permite representar secuencias que como arrays serían imposibles:
El bucle es literalmente while (true) y, aun así, el programa termina. ¿El motivo? Un generador solo avanza cuando alguien le pide el siguiente valor. Puedes quedarte con los primeros N elementos, cortar ahí, y el resto nunca llega a ejecutarse:
take es, en sí mismo, un generador que envuelve a otro. Componer generadores de esta forma es buena parte de su atractivo: piezas pequeñas, cada una con una única responsabilidad.
Delegar con yield*
Cuando un generador necesita emitir todos los valores de otro iterable, yield* se encarga de delegar:
yield* funciona con cualquier iterable: arrays, sets u otros generadores. Va reenviando los elementos uno a uno, como si fuera el equivalente del spread pero para iteradores.
Generadores asíncronos, en breve
Un generador declarado como async function* puede hacer yield de valores que tardan en llegar. Resulta muy útil cuando necesitas hacer streaming desde una API o leer un archivo por trozos. Para consumirlo se usa for await...of:
async function* paginate(url) {
let next = url;
while (next) {
const res = await fetch(next);
const page = await res.json();
for (const item of page.items) yield item;
next = page.nextUrl;
}
}
for await (const item of paginate("/api/users")) {
console.log(item);
}
Este fragmento no se puede ejecutar aquí (necesita un endpoint real), pero vale la pena saber que esa forma existe. Una vez que tengas claros los generadores normales, los generadores asíncronos son la misma idea pero con await por medio.
Cuándo conviene usar un generador
Úsalos cuando:
- La secuencia es infinita o podría serlo: IDs, timestamps, tiempos de reintento.
- Generar todos los valores es costoso y el consumidor podría cortar antes de llegar al final.
- Estás implementando
Symbol.iteratoren un objeto propio. Casi siempre queda más corto que armar a mano el objeto{ next() }. - Quieres encadenar transformaciones tipo streaming (
take,filter,map) sin ir creando arrays intermedios.
Si los datos ya están en memoria y son pocos, tira de un array común y corriente. Los generadores no salen gratis: toda la maquinaria que suspende y reanuda la función tiene su coste, y seguir un stack trace que pasa por código de generadores puede ser más incómodo de leer.
Siguiente: Symbols
Symbol.iterator es el primer símbolo con el que casi todo el mundo se topa, pero ni de lejos es el único. Los símbolos son un tipo primitivo pensado justo para este tipo de puntos de extensión: claves únicas que permiten al lenguaje —y a tu propio código— engancharse a los objetos sin chocar con los nombres de propiedades normales. Eso lo vemos en la próxima página.
Preguntas frecuentes
¿Qué diferencia hay entre un iterable y un iterador en JavaScript?
Un iterable es cualquier objeto que tenga un método Symbol.iterator que devuelva un iterador. El iterador es el objeto que realmente va produciendo los valores: tiene un método next() que devuelve { value, done }. Los arrays, los strings, Map y Set son iterables; si llamas a su método Symbol.iterator obtienes un iterador por el que puedes ir avanzando paso a paso.
¿Qué es una función generadora en JavaScript?
Es una función declarada con function* que produce valores de forma perezosa usando yield. Al llamarla no se ejecuta el cuerpo: lo que obtienes es un objeto generador que es a la vez iterador e iterable. Cada llamada a next() ejecuta el código hasta el siguiente yield, se pausa y devuelve el valor producido.
¿Cuál es la diferencia entre yield y return dentro de un generador?
yield pausa el generador y devuelve un valor, pero la función puede retomar la ejecución justo donde la dejó en la siguiente llamada a next(). return, en cambio, termina el generador para siempre: marca done: true y ya no salen más valores. Puedes hacer yield tantas veces como quieras; return solo tiene sentido una vez.
¿Cuándo conviene usar un generador en lugar de un array?
Cuando la secuencia es infinita, cara de calcular o solo vas a necesitar algunos de los valores. Un generador va produciendo los elementos uno a uno bajo demanda, así que puedes representar un flujo interminable de IDs o resultados paginados de una API sin tener que materializarlo todo en memoria. Si ya tienes un array pequeño y fijo, con un array tienes de sobra.