"JavaScript Generators Made Easy: A Beginner's Roadmap to Effortless Iteration"

What are Generator Functions:

A Generator-function is defined just like a normal function but with a function* symbol. And where the normal functions return the value, Generator functions yield the value. More about this 'yield' and how it works in a bit, first let's see how it's basic structure is like:

function* generatorFunc(){     // Ofcourse, you can name it whatever
  // some code... 
 let id = 1
 yield id;
}

Yeah, just that, just two changes in its look from a normal function.

Now let's see how it behaves and what are its use cases that you should know

Working with multiple 'yield' statements

As you know, return keyword in a function wherever we need to stop the execution and just return some value from a function. So if the execution flow encounters a return it does not proceed further in the function's scope.

But with the yield it is a bit different, yes it returns a value, but it is not a permanent exit from the function scope, but rather a pause. Let's see it in action:-

  // generator function
function* generatorFunc() {

    console.log("before 100 yield");
    yield 100;

   console.log("before 200 yield");
    yield 200;
}

// returns generator object
const generatorObj = generatorFunc();

console.log(generatorObj.next());
before 100 yield
{value: 100, done: false}

Yes, we do have and can have multiple yield. So what's happening here is that we create an object instance named 'generatorObj' of generatorFunc();

Generator functions have three inbuilt methods return(), next() and throw() inside it. The object instance(generatorObj in above case) is used to call these methods to generate a value from the function.

The value object comes in the form of an object {value: 100, done: false} . It means that the function generated this object once (or rather yielded only value: 100). as we call the instances' next() method only once. The done flag done: false is false because there are more yield statements still to come in the function scope.

To return 200, we need to call the next() method once again from the same object instance (generatorObj).

function* generatorFunc() {

    console.log("before 100 yield");
    yield 100;

   console.log("before 200 yield");
    yield 200;
   console.log("after 200 yield");
}

// returns generator object
const generatorObj = generatorFunc();


console.log(generatorObj.next());
console.log(generatorObj.next());
console.log(generatorObj.next());
before 100 yield
{value: 100, done: false}
before 200 yield
{value: 200, done: false}
after 200 yield
{value: undefined, done: true}

In the above example, calling next() second time, yields a second value i.e; 200. But calling it third time, it returns undefined and done:true .This tells us that there are no values left now to yield ( or all the yields are done). So it provides with the {value: undefined, done: true}.

The Image below describes the control flow more accurarely.

Passing Arguments in generator functions

Yes, just like a other methods, we can pass arguments in generator function in next().

// generator function
function* generatorFunc() {

    // returns 'hello' at first next()
    let x = yield 'first yield';

    // returns passed argument on the second next()
    console.log(x);
    console.log('some code');

    // returns 5 on second next()
    yield 5;

}

const generator = generatorFunc();

console.log(generator.next());
console.log(generator.next(6));
console.log(generator.next());
  • The first generator.next() returns the value of the yield (in this case, 'first yield'). However, the value is not assigned to variable x in let x = yield 'hello';

      {value: "hello", done: false}
    
  • When generator.next(6) is encountered, the code again starts at let x = yield 'hello'; and the argument 6 is assigned to x. Also, the remaining code is executed up to the second yield.

  •   6
      some code
      {value: 5, done: false}
    
  • When the third next() is executed, the program returns {value: undefined, done: true}. It is because there are no other yield statements.

  •   {value: undefined, done: true}
    

JavaScript Generator Function With return.

We can use both yield and return statements together in the generator function as well. The return statement will terminate the function(just like regular functions.)

Let's see

// generator function
function* generatorFunc() {

    yield 100;
    return 123;
    console.log("2. some code before second yield");
    yield 200;
}

// returns generator object
const generator = generatorFunc();

console.log(generator.next());
console.log(generator.next());
console.log(generator.next());

output:

{value: 100, done: false}
{value: 123, done: true}
{value: undefined, done: true}

Here, when the function encounters the 'return'. And it returns the value: 123, done: true. And it is returning on the second generator.next() call.

The third generator.next() call will give undefined because the yield 200 is not processed and also the console.log statement.

Note: You can also use the return() method instead of the return statement like generator.return(123); in the above code.

Throwing exceptions using 'generator.throw()'.

Besides, next(), and return() method, generator object also provides throw() method. It is the least useful, but helps in throwing error when an API call fails with error, or any other code throws the exception.

function* numberGenerator() {
  try {
    yield 1;
    yield 2;
    yield 3;
  } catch (error) {
    console.log("Error:", error);
  }
}

const generator = numberGenerator();

console.log(generator.next()); // { value: 1, done: false }
console.log(generator.next()); // { value: 2, done: false }

generator.throw("Something went wrong!"); // Error: Something went wrong!

console.log(generator.next()); // { value: undefined, done: true }

Creating Iterators with Generators

In Programming Languages, Iterator is a concept where it allows you to loop or iterate over a collection or sequence of values, fetching one value at a time until the end of the collection is reached. It provides a consistent and predictable way to traverse through data structures like arrays, strings, maps, sets, and custom objects.

function* numberIterator(start, end) {
  for (let i = start; i <= end; i++) {
    yield i;
  }
}

const iterator = numberIterator(1, 5);

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

In this example, we define a generator function numberIterator that generates numbers from a given start to end values. The yield statement is used to emit each value one by one. We then create an iterator iterator by invoking the generator function. We can use the iterator's next() method to retrieve the next value in the sequence until the iterator is done (done is true).

Advantages of creating custom iterators

  1. Lazy Evaluation: Iterators allow for lazy evaluation of values, meaning that the next value is computed only when requested. This can be useful for processing large or infinite sequences of data, as it avoids unnecessary computation upfront.

  2. Memory Efficiency: By generating values on-demand, iterators can be memory efficient, especially when dealing with large datasets. Instead of loading all the data into memory at once, you can fetch and process values one at a time, reducing memory consumption.

  3. Custom Iteration Logic: With iterators, you have control over the iteration logic. You can define how values are generated, skipped, filtered, transformed, or combined. This flexibility allows you to tailor the iteration process to your specific needs.

  4. Asynchronous Iteration: Iterators can also be used to handle asynchronous operations in a sequential and controlled manner. With the introduction of async generators, you can create iterators that produce asynchronous values, enabling you to work with asynchronous data streams in a more structured way.

Overall, iterators provide a powerful abstraction for working with collections and sequences of data, allowing for efficient, customizable, and composable iteration processes. They enhance code readability, modularity, and performance in various JavaScript applications.

Uses of Generators

  • Generators let us write cleaner code while writing asynchronous tasks.

  • Generators provide an easier way to implement iterators.

  • Generators execute their code only when required.

  • Generators are memory efficient.

Generators were introduced in ES6. Some browsers may not support the use of generators. To learn more, visit JavaScript Generators support.