DEV Community

Ben Mildren
Ben Mildren

Posted on

Sets in ES6 - A Quick Guide

Overview

Sets are a new data type in ES6.

They're similar to Arrays with one major difference, each value must be unique. They have an API that's very similar to Maps. They're a little bit simpler than Maps though so don't worry!

Sets are instantiated as you'd expect: either empty or with an iterable.

const foo = new Set()

const foo1 = new Set([1,2,3,4,5])

You can add to the Set with the .add() method. As it returns this, you can chain them.

Set.prototype.add(value) : this

foo.add('hello')

foo.add('hey there')
   .add('yo')

You can also check to see if a value exists with .has() method.

Set.prototype.add(value) : boolean

foo.has('yo') // true

foo.has('greetings') // false

You may have noticed a pattern with this API in comparison to Maps now and removing items is no different with the .delete() and .clear() methods. .remove() returns a boolean depending on if the value existed or not.

Set.prototype.delete(value) : boolean

foo.remove('yo') // true

foo.remove('greetings') // false

Set.prototype.clear() : undefined

foo.clear() // undefined

The static property .size returns the amount of items in the Set.

Set.prototype.size : number

const bar = new Set([1,2,3,4,5])

bar.size

Set API Summary

There a few other methods for iteration which we'll cover in the next section, but these are the basics.

const foo = new Set()

foo.add(key, value) 

foo.get(key)

foo.has(key)

foo.delete(key)

foo.clear()

foo.size

Usage

Special snowflake

Remember the big difference between Arrays and Sets? In a Set, a value cannot occur more than once. So each value is definitely unique. This immediately has some fantastic use cases. I know I've certainly reached for Lodash's _.uniq functions before, well now you don't need to.

const myArray = [1,1,2,2,3,3]

const mySet = new Set(myArray)

const uniqueArray = [...mySet]

This is pretty powerful, although it would be nice for Arrays to have a built in method for this function. (Side note: ... and Array.from are the slowest methods to do this, with for...of being the quickest on modern browsers. This is a real shame as the first 2 are so much cleaner. Source)

Iteration

Something not immediately obvious is that you cannot retrieve values from a Set using the Array index method [n]. This is because Sets are not indexed.

The only way to use the values in a Set is through iteration, and for this reason they're stored in insertion order. You can use the .forEach() method to iterate over a Set.

const foo = new Set(1,2,3,4)

Set.prototype.forEach(callback(val1, val2, Set), [thisArg]) : undefined

foo.forEach(i => console.log(i)) // 1, 2, 3, 4

Strangely, the callback you pass to .forEach() accepts 3 arguments. Both of the first 2 args are the same thing: the value. I imagine they duplicated the value to conform with the same API that Maps have (where they have a key and a value). The third value is the current Set you're iterating over. A number of methods have this argument, and I've never figured out why (please comment if you know why). Finally, you can optionally set the value of this.

Sets also use the iterator protocol which allows you to use Sets with for...of loops.

for (let i of foo) {
  console.log(i) // 1, 2, 3, 4, undefined
}

Maps have a .keys(), a .values() and a .entries() method. Sets do not have keys so they do not have a .keys() method, but they do have the other two.

Set.prototype.values() : Set iterator

const vals = foo.values() // SetIterator {1,2,3,4}

for (let val of vals) {
  console.log(val) // 1, 2, 3, 4
}

Set.prototype.entries() : Set iterator

const ents = foo.entries() // SetIterator {1,2,3,4}

for (let ent of ents) {
  console.log(ent) // [1, 1], [2, 2], [3, 3], [4, 4]
}

Should I use an Set or a Array?

When we asked a similar question for Maps and Objects, Maps seemed like the better choice quite often, but it was a pretty good split on pros and cons for the pair of them. Unfortunately for Set, I think the answer here is much more one sided.

Arrays are almost always more useful in every day life. Sets having only unique items within them is definitely helpful, I just don't see many more things that I'd honestly pick a Set for over an Array. Sure, there are certain situations where Sets excel, but they're so few and far between that I'd reach for an Array basically every time.

That said, do read the details and caveats below as there are definitely certain situations where a Set is more performant or more practical.

Details & Caveats

What is equal?

Like Maps, Sets use something called SameValueZero to determine if a Set has a value.

SameValueZero is very similar to === but has 2 notable differences:

  • NaN is equal to NaN
  • +0 is equal to -0
NaN === NaN // false

const foo = new Set()

foo.add(NaN)
foo.size // 1

foo.add(NaN)
foo.size // 1

Using Array methods

Converting between Sets and Arrays is so easy, using Array methods on Sets is easy!

const foo = new Set([2,1])

const sortedArray = [...foo].sort()

const sortedSet = new Set(sortedArray) // 1, 2

Or even shorted (but slightly less clear)

const foo = new Set([2,1])

const sortedSet = new Set([...foo].sort()) // 1, 2

WeakSet

WeakSets are a separate data type, but they are very similar to Sets.

This is the full WeakSet API:

WeakSet.prototype.add(value) : this
WeakSet.prototype.delete(value) : boolean
WeakSet.prototype.has(value) : boolean

As we can see, this just like the Set API but with a few things removed.

It is worth noting that WeakSet.prototype.clear() used to be apart of the spec, but has been removed. Although this may work with certain browsers, you should not use this as it will be unsupported soon.

There are two fundamental differences between WeakSets and Sets:

  • You can only add objects to WeakSets, no primitives
  • WeakSets allow their values to be garbage collected

Let's see the first point in action: no primitives.

const foo = new WeakSet()

foo.add(1) // TypeError: Invalid value used in weak set

foo.add('hello') // TypeError: Invalid value used in weak set

foo.add({}) // ✅

That's straightforward, but the second point is a little harder to grasp without good understanding of how garbage collection works in modern browsers (I'll be writing a blog about that shortly).

Browsers hold on to objects in memory for as long as they think that object is "reachable". If you were to store a value in a Set, and that value became unreachable anywhere else, it'll still be reachable in the Set and thus the garbage collector will not remove it from memory.

const foo = new Set()

let someObj = { name: 'Ben' }

foo.add(someObj)

someObj = null

foo.size() // 1

In the above example, foo holds on to the value and stops it being garbage collected. WeakSets hold on to their values weakly and will allow garbage collectors to remove them.

const foo = new WeakSet()

let someObj = { name: 'Ben' }

foo.add(someObj)

someObj = null

Unfortunately, there is no way to check that the object has been removed from foo, but rest assured it will be once a garbage collection happens.

WeakSets have such a tiny API that I cannot see many every day use cases for them. There is potentially some very niche cases such as marking certain items. Say you need to know if you've marked an item that you've previously iterated over. You could add another property to the object or you could add that item to a WeakSet.

const foo = new WeakSet()

const someArr = [
  { name: 'Ben' },
  { name: 'Zoe' },
  { name: 'Roman' }
]

for (let value of someArr) {
  // Flipping a coin
  if(Math.random > 0.5) {
    foo.add(value)
  }
}

// You can check later if that value got a heads or tails
foo.has(someArr[0])

Assuming that item becomes unreachable in the future, it will also be removed from the WeakSet.

This all feels like a stretch to me if I'm honest. It just feels like I would basically never reach for a WeakSet. I'd love for someone to comment and let me know some use cases for WeakSets.

Top comments (3)

Collapse
 
misterwhat profile image
Jonas Winzen • Edited

The third value is the current Set you're iterating over. A number of methods have this argument, and I've never figured out why

This allows you to reuse the callback in several places, without losing the flexibility you have, when defining the callback inline. From a callback that is defined somewhere else, you will loose the ability to look at the object you are currently working.
Use case: transform an array of values to an srray of strings, that have the form `${value} of ${array.length} items`.
Without the third parameter, you'd need to write a higher order function that takes the length (or the array itself) returns a function that can be used as a callback function:

const createCallback = array => (index, value) => (
value + " of " + array.length + " items"
);
const inputArray = ["foo", "bar"];
console.log(inputArray.map(createCallback(inputArray)));

Code like this is something what I call an "eyebrow raiser". What happens if someone changes the createCallback function and mutates the array there (think of the poor guy, who has to debug that... )? You need to take care to have tests in place that protect the code from this kind of change (super edgy edge case that is super dangerous at the same time).

Collapse
 
antjanus profile image
Antonin J. (they/them) • Edited

More than that! You need it if you chain methods like so:

myArr
  .filter(val => !!val)
  .forEach((val, idx, filteredArr) => {
    // now has access to the filtered array via `filteredArr`
  })
  .map((val, idx, filteredArr) => {

  })
  .forEach((val, idx, mappedArr) => {
    // now has access to the filtered and mapped array via `mappedArr`
  });

Otherwise, you'd have to execute and assign these to variables and reach out of the inner scope of the call back function.

Collapse
 
realdolos profile image
Dolores Greatamsky • Edited

Some minor corrections:

  • You wrote foo.add(key, value) instead of foo.add(key) in your API overview.
  • You wrote "Sets do not have keys so they do not have a .keys()". Actually they do; it's just an alias for .values(). This is so that they provide the same iteration API functions as Maps, which can be useful when writing certain algorithms which will operate on container keys and/or values. Same reason Sets have .entries(). The important difference being that @@iterator maps to Map.entries() but Set.values().