Sets in ECMAScript 6

ECMAScript 6 adds a Set type that is an ordered list of values without duplicates. Sets allow fast access to the data they contain, adding a more efficient manner of tracking discrete values.

Creating Sets and Adding Items

Sets are created using new Set() and items are added to a set by calling the add() method. You can see how many items are in a set by checking the size property:

  1. let set = new Set();
  2. set.add(5);
  3. set.add("5");
  4. console.log(set.size); // 2

Sets do not coerce values to determine whether they are the same. That means a set can contain both the number 5 and the string "5" as two separate items. (The only exception is that -0 and +0 are considered to be the same.) You can also add multiple objects to the set, and those objects will remain distinct:

  1. let set = new Set(),
  2. key1 = {},
  3. key2 = {};
  4. set.add(key1);
  5. set.add(key2);
  6. console.log(set.size); // 2

Because key1 and key2 are not converted to strings, they count as two unique items in the set. (Remember, if they were converted to strings, they would both be equal to "[object Object]".)

If the add() method is called more than once with the same value, all calls after the first one are effectively ignored:

  1. let set = new Set();
  2. set.add(5);
  3. set.add("5");
  4. set.add(5); // duplicate - this is ignored
  5. console.log(set.size); // 2

You can initialize a set using an array, and the Set constructor will ensure that only unique values are used. For instance:

  1. let set = new Set([1, 2, 3, 4, 5, 5, 5, 5]);
  2. console.log(set.size); // 5

In this example, an array with duplicate values is used to initialize the set. The number 5 only appears once in the set even though it appears four times in the array. This functionality makes converting existing code or JSON structures to use sets easy.

I> The Set constructor actually accepts any iterable object as an argument. Arrays work because they are iterable by default, as are sets and maps. The Set constructor uses an iterator to extract values from the argument. (Iterables and iterators are discussed in detail in Chapter 8.)

You can test which values are in a set using the has() method, like this:

  1. let set = new Set();
  2. set.add(5);
  3. set.add("5");
  4. console.log(set.has(5)); // true
  5. console.log(set.has(6)); // false

Here, set.has(6) would return false because the set doesn’t have that value.

Removing Values

It’s also possible to remove values from a set. You can remove single value by using the delete() method, or you can remove all values from the set by calling the clear() method. This code shows both in action:

  1. let set = new Set();
  2. set.add(5);
  3. set.add("5");
  4. console.log(set.has(5)); // true
  5. set.delete(5);
  6. console.log(set.has(5)); // false
  7. console.log(set.size); // 1
  8. set.clear();
  9. console.log(set.has("5")); // false
  10. console.log(set.size); // 0

After the delete() call, only 5 is gone; after the clear() method executes, set is empty.

All of this amounts to a very easy mechanism for tracking unique ordered values. However, what if you want to add items to a set and then perform some operation on each item? That’s where the forEach() method comes in.

The forEach() Method for Sets

If you’re used to working with arrays, then you may already be familiar with the forEach() method. ECMAScript 5 added forEach() to arrays to make working on each item in an array without setting up a for loop easier. The method proved popular among developers, and so the same method is available on sets and works the same way.

The forEach() method is passed a callback function that accepts three arguments:

  1. The value from the next position in the set
  2. The same value as the first argument
  3. The set from which the value is read

The strange difference between the set version of forEach() and the array version is that the first and second arguments to the callback function are the same. While this might look like a mistake, there’s a good reason for the behavior.

The other objects that have forEach() methods (arrays and maps) pass three arguments to their callback functions. The first two arguments for arrays and maps are the value and the key (the numeric index for arrays).

Sets do not have keys, however. The people behind the ECMAScript 6 standard could have made the callback function in the set version of forEach() accept two arguments, but that would have made it different from the other two. Instead, they found a way to keep the callback function the same and accept three arguments: each value in a set is considered to be both the key and the value. As such, the first and second argument are always the same in forEach() on sets to keep this functionality consistent with the other forEach() methods on arrays and maps.

Other than the difference in arguments, using forEach() is basically the same for a set as it is for an array. Here’s some code that shows the method at work:

  1. let set = new Set([1, 2]);
  2. set.forEach(function(value, key, ownerSet) {
  3. console.log(key + " " + value);
  4. console.log(ownerSet === set);
  5. });

This code iterates over each item in the set and outputs the values passed to the forEach() callback function. Each time the callback function executes, key and value are the same, and ownerSet is always equal to set. This code outputs:

  1. 1 1
  2. true
  3. 2 2
  4. true

Also the same as arrays, you can pass a this value as the second argument to forEach() if you need to use this in your callback function:

  1. let set = new Set([1, 2]);
  2. let processor = {
  3. output(value) {
  4. console.log(value);
  5. },
  6. process(dataSet) {
  7. dataSet.forEach(function(value) {
  8. this.output(value);
  9. }, this);
  10. }
  11. };
  12. processor.process(set);

In this example, the processor.process() method calls forEach() on the set and passes this as the this value for the callback. That’s necessary so this.output() will correctly resolve to the processor.output() method. The forEach() callback function only makes use of the first argument, value, so the others are omitted. You can also use an arrow function to get the same effect without passing the second argument, like this:

  1. let set = new Set([1, 2]);
  2. let processor = {
  3. output(value) {
  4. console.log(value);
  5. },
  6. process(dataSet) {
  7. dataSet.forEach((value) => this.output(value));
  8. }
  9. };
  10. processor.process(set);

The arrow function in this example reads this from the containing process() function, and so it should correctly resolve this.output() to a processor.output() call.

Keep in mind that while sets are great for tracking values and forEach() lets you work on each value sequentially, you can’t directly access a value by index like you can with an array. If you need to do so, then the best option is to convert the set into an array.

Converting a Set to an Array

It’s easy to convert an array into a set because you can pass the array to the Set constructor. It’s also easy to convert a set back into an array using the spread operator. Chapter 3 introduced the spread operator (...) as a way to split items in an array into separate function parameters. You can also use the spread operator to work on iterable objects, such as sets, to convert them into arrays. For example:

  1. let set = new Set([1, 2, 3, 3, 3, 4, 5]),
  2. array = [...set];
  3. console.log(array); // [1,2,3,4,5]

Here, a set is initially loaded with an array that contains duplicates. The set removes the duplicates, and then the items are placed into a new array using the spread operator. The set itself still contains the same items (1, 2, 3, 4, and 5) it received when it was created. They’ve just been copied to a new array.

This approach is useful when you already have an array and want to create an array without duplicates. For example:

  1. function eliminateDuplicates(items) {
  2. return [...new Set(items)];
  3. }
  4. let numbers = [1, 2, 3, 3, 3, 4, 5],
  5. noDuplicates = eliminateDuplicates(numbers);
  6. console.log(noDuplicates); // [1,2,3,4,5]

In the eliminateDuplicates() function, the set is just a temporary intermediary used to filter out duplicate values before creating a new array that has no duplicates.

Weak Sets

The Set type could alternately be called a strong set, because of the way it stores object references. An object stored in an instance of Set is effectively the same as storing that object inside a variable. As long as a reference to that Set instance exists, the object cannot be garbage collected to free memory. For example:

  1. let set = new Set(),
  2. key = {};
  3. set.add(key);
  4. console.log(set.size); // 1
  5. // eliminate original reference
  6. key = null;
  7. console.log(set.size); // 1
  8. // get the original reference back
  9. key = [...set][0];

In this example, setting key to null clears one reference of the key object, but another remains inside set. You can still retrieve key by converting the set to an array with the spread operator and accessing the first item. That result is fine for most programs, but sometimes, it’s better for references in a set to disappear when all other references disappear. For instance, if your JavaScript code is running in a web page and wants to keep track of DOM elements that might be removed by another script, you don’t want your code holding onto the last reference to a DOM element. (That situation is called a memory leak.)

To alleviate such issues, ECMAScript 6 also includes weak sets, which only store weak object references and cannot store primitive values. A weak reference to an object does not prevent garbage collection if it is the only remaining reference.

Creating a Weak Set

Weak sets are created using the WeakSet constructor and have an add() method, a has() method, and a delete() method. Here’s an example that uses all three:

  1. let set = new WeakSet(),
  2. key = {};
  3. // add the object to the set
  4. set.add(key);
  5. console.log(set.has(key)); // true
  6. set.delete(key);
  7. console.log(set.has(key)); // false

Using a weak set is a lot like using a regular set. You can add, remove, and check for references in the weak set. You can also seed a weak set with values by passing an iterable to the constructor:

  1. let key1 = {},
  2. key2 = {},
  3. set = new WeakSet([key1, key2]);
  4. console.log(set.has(key1)); // true
  5. console.log(set.has(key2)); // true

In this example, an array is passed to the WeakSet constructor. Since this array contains two objects, those objects are added into the weak set. Keep in mind that an error will be thrown if the array contains any non-object values, since WeakSet can’t accept primitive values.

Key Differences Between Set Types

The biggest difference between weak sets and regular sets is the weak reference held to the object value. Here’s an example that demonstrates that difference:

  1. let set = new WeakSet(),
  2. key = {};
  3. // add the object to the set
  4. set.add(key);
  5. console.log(set.has(key)); // true
  6. // remove the last strong reference to key, also removes from weak set
  7. key = null;

After this code executes, the reference to key in the weak set is no longer accessible. It is not possible to verify its removal because you would need one reference to that object to pass to the has() method. This can make testing weak sets a little confusing, but you can trust that the reference has been properly removed by the JavaScript engine.

These examples show that weak sets share some characteristics with regular sets, but there are some key differences. Those are:

  1. In a WeakSet instance, the add() method throws an error when passed a non-object (has() and delete() always return false for non-object arguments).
  2. Weak sets are not iterables and therefore cannot be used in a for-of loop.
  3. Weak sets do not expose any iterators (such as the keys() and values() methods), so there is no way to programmatically determine the contents of a weak set.
  4. Weak sets do not have a forEach() method.
  5. Weak sets do not have a size property.

The seemingly limited functionality of weak sets is necessary in order to properly handle memory. In general, if you only need to track object references, then you should use a weak set instead of a regular set.

Sets give you a new way to handle lists of values, but they aren’t useful when you need to associate additional information with those values. That’s why ECMAScript 6 also adds maps.