JavaScript Symbols, Iterators, Generators, Async/Await, and Async Iterators — All Explained Simply

JavaScript is constantly evolving, with new features being added to the language specification each year. Several of the more recent additions—namely symbols, iterators, generators, async/await, and async iterators—can seem quite cryptic at first glance. However, once you understand why they were introduced and how they work under the hood, you‘ll see that they are actually quite elegant and powerful tools for writing more expressive and maintainable code.

In this article, I‘ll break down each of these features in simple terms, explaining the rationale behind them and walking through practical examples of how to use them. Whether you‘re a seasoned JavaScript developer looking to get up to speed with the latest language features or a beginner trying to make sense of some unfamiliar syntax, by the end of this post you should have a solid grasp of these concepts. Let‘s dive in!

Symbols

Symbols were introduced in ES6 (ECMAScript 2015) as a new primitive type, joining the ranks of strings, numbers, booleans, null, and undefined. But what exactly are symbols and why do we need them?

In essence, a symbol is a unique identifier. You create a symbol by invoking the Symbol function:

const sym = Symbol();

Every time you invoke Symbol(), it returns a new unique symbol value. Even if you provide the same description, the symbols will not be equal:

const sym1 = Symbol(‘mySymbol‘);
const sym2 = Symbol(‘mySymbol‘);

console.log(sym1 === sym2); // false

The primary use case for symbols is as property keys on objects. Prior to ES6, object properties could only be strings. Symbols allow you to define non-string properties that won‘t collide with any other properties. They are not enumerable in for…in loops or Object.keys(), making them ideal for defining special, hidden properties.

Here‘s an example of using a symbol as a property key:

const MY_KEY = Symbol();

const obj = {};
obj[MY_KEY] = 123;

console.log(obj[MY_KEY]); // 123

Symbols have a few other special properties and use cases, such as well-known symbols that can be used to customize the behavior of built-in operations. But their main purpose is to serve as unique, non-string property keys.

Iterators and Iterables

Iterators are objects that define a sequence and potentially a return value upon completion. An iterable is an object that has an @@iterator method which returns an iterator. The built-in iterables in JavaScript include arrays, strings, maps, sets, and others.

The iterator protocol states that an object is an iterator when it has a next() method that returns an object with two properties:

  • value: the next value in the sequence
  • done: a boolean indicating whether the sequence has ended

Here‘s an example of a custom iterable that loops through the characters in a string:

const myIterable = {
  [Symbol.iterator]() {
    let i = 0;
    const str = ‘hello‘;
    return {
      next() {
        if (i < str.length) {
          return { value: str[i++], done: false };
        } else {
          return { value: undefined, done: true };
        }
      }
    };
  }
};

We can then loop through the iterable using a for…of loop:

for (const char of myIterable) {
  console.log(char);
}
// Output:
// h
// e
// l
// l
// o

Whenever you use a for…of loop, destructure an array, or spread an array into another array, the @@iterator method is invoked behind the scenes.

Generators

While custom iterators are powerful, they can be a bit verbose and cumbersome to write. Generator functions provide a cleaner, more concise way to create iterators.

A generator function is defined by putting an asterisk (*) after the function keyword. Instead of returning a value, generator functions yield values. Here‘s an example:

function* myGenerator() {
  yield 1;
  yield 2;
  yield 3;
}

When you invoke a generator function, it doesn‘t actually run any of the function‘s code. Instead, it returns a generator object which is an iterator. When you call the generator‘s next() method, the function will execute until it hits a yield expression, which specifies the value to return from the iterator.

const myGen = myGenerator();

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

One of the most powerful aspects of generators is that they allow two-way communication. Not only can they yield values to the outside, but they can also receive values from the outside via the next() method.

Here‘s an example that demonstrates this:

function* myGenerator() {
  const x = yield 1;
  const y = yield 2;
  console.log(x, y); 
}

const myGen = myGenerator();

console.log(myGen.next()); // { value: 1, done: false }
console.log(myGen.next(10)); // { value: 2, done: false }
console.log(myGen.next(20)); // { value: undefined, done: true }
// Output: 10, 20

This ability to pause execution and send values back into a generator is the foundation for how async/await works, which we‘ll cover next.

Async/Await

Promises have become the standard way to handle asynchronous operations in JavaScript. However, working with promises can still involve a fair amount of boilerplate and indentation. Async/await provides a way to write promise-based code as if it were synchronous, making it much cleaner and easier to follow.

An async function is defined by prepending the async keyword to a function declaration. Async functions always return a promise, and the await keyword can be used inside them to pause execution on a promise-returning expression.

Here‘s a simple example:

async function fetchData(url) {
  const response = await fetch(url);
  const data = await response.json();
  return data;
}

The await keyword causes the function to pause until the promise settles. If the promise fulfills, await returns the fulfilled value. If the promise rejects, await throws the rejected value.

Here‘s how we would use our async fetchData function:

fetchData(‘https://api.example.com/data‘)
  .then(data => console.log(data))
  .catch(error => console.error(error));

Under the hood, async/await is built on top of generators and promises. When you use await inside an async function, it‘s equivalent to yielding a promise from a generator.

Async Iterators

ES2018 introduced async iterators, which combine the concepts of async/await and iterators. An async iterator is created by defining an @@asyncIterator method on an object, which returns a promise for an iterator.

The for-await-of loop is used to loop through async iterables, just like the for-of loop is used for synchronous iterables. Here‘s an example:

const myAsyncIterable = {
  async *[Symbol.asyncIterator]() {
    yield ‘hello‘;
    yield ‘async‘;
    yield ‘world‘;
  }
};

(async function() {
  for await (const x of myAsyncIterable) {
    console.log(x);
  }
})();
// Output:
// hello
// async 
// world

Async iterators open up some powerful possibilities, such as being able to iterate over data that comes from an asynchronous source like a stream.

Conclusion

JavaScript symbols, iterators, generators, async/await, and async iterators are all relatively advanced language features, but hopefully this article has demystified them and shown you how they can be very useful tools in your JavaScript toolbelt.

Symbols provide a way to create hidden, unique properties on objects. Iterators and the iterable protocol define a standard way to loop through data sequentially. Generators provide a concise syntax for creating iterators and allow bidirectional communication. Async/await builds on top of promises and generators to make asynchronous code look synchronous. And async iterators bring the power of async/await to the world of iterables.

By understanding these features, you‘ll be better equipped to understand modern JavaScript codebases and write cleaner, more expressive, and more powerful code yourself. I hope you found this deep dive useful!

Similar Posts