This whole module has been about key-value data. Maps gave us fast lookup by key. Structs gave us maps with a checked set of keys. Then we moved a tetromino around as a struct and converted it into points a screen could draw.
Now the last type in the family is a set.
That should feel a little strange. A set is not how we usually talk about key-value data. You do not ask, “What value is stored under this key?” You ask a simpler question: is this thing a member?
Bruce names the reason in class:
“The reason I lumped this in with key-value data is that the underlying implementation depends on maps.”
— Bruce Tate
That is why MapSet belongs here. It gives you the vocabulary of sets, but the machinery underneath is still map-shaped. The surface says membership. The implementation says keys.
Making a Set
You can start empty:
empty = MapSet.new()
#=> MapSet.new([])
Or you can build a set from something Enumerable:
eight = MapSet.new(1..8)
#=> MapSet.new([1, 2, 3, 4, 5, 6, 7, 8])
That second line is doing more than it first appears to do. 1..8 is a range, not a list. MapSet.new/1 walks it because ranges satisfy the Enumerable contract, the same contract we saw earlier with maps.
That means lists, ranges, streams, and map entries can all become sets without a separate conversion step. The constructor only needs one promise: give me something I can enumerate, and I will turn its elements into members.
Once you have a set, the natural question is membership:
MapSet.member?(eight, 9)
#=> false
MapSet.member?(eight, 8)
#=> true
Notice the argument order. The set comes first because it is the module’s main type. That is the same convention you saw with Map.get/2 and Map.put/3: the thing the module is about usually comes first, which also makes the API easy to pipe through.
🎯 Join Groxio's Newsletter
Weekly lessons on Elixir, system design, and AI-assisted development -- plus stories from our training and mentoring sessions.
We respect your privacy. No spam, unsubscribe anytime.
Proving the Secret
Bruce does not ask you to trust the abstraction from a distance. He inspects it:
i eight
The shell reports the type as MapSet, and it also tells you this is a struct. That matters because a struct can be pattern matched:
%MapSet{} = eight
#=> MapSet.new([1, 2, 3, 4, 5, 6, 7, 8])
%Date{} = eight
#=> ** (MatchError)
The first match succeeds because the value really has the shape of a MapSet struct. The second fails because it does not have the shape of a Date.
And since a struct is a map, you can ask for its keys:
Map.keys(eight)
#=> [:__struct__, :map, :version]
There is :__struct__, doing the same job it did for the structs we already studied. There is :map, where the membership data lives. There is also :version, which is internal bookkeeping. Treat it that way. The public contract is the MapSet API, not the private fields inside the struct.
But the mental model is now visible: a MapSet is a struct with map data inside it.
What a Set Refuses to Do
Add a value with MapSet.put/2:
MapSet.new()
|> MapSet.put(1)
#=> MapSet.new([1])
Now add the same value twice:
MapSet.new()
|> MapSet.put(1)
|> MapSet.put(1)
#=> MapSet.new([1])
Nothing changes. A set does not remember how many times you added something. It only remembers whether the thing is a member.
Order does not matter either:
MapSet.new([1, 2, 3]) == MapSet.new([3, 2, 1])
#=> true
That is the shift from lists to sets. A list preserves sequence. A set preserves membership. If the order matters, use a list. If the question is “have I seen this before?” or “is this allowed?”, a set is usually the better shape.
Venn Diagrams in Code
If you remember overlapping circles from school, this API already makes sense:
one_four = MapSet.new(1..4)
five_eight = MapSet.new(5..8)
two_six = MapSet.new(2..6)
MapSet.disjoint?(one_four, five_eight)
#=> true
MapSet.intersection(one_four, two_six)
#=> MapSet.new([2, 3, 4])
MapSet.difference(one_four, two_six)
#=> MapSet.new([1])
MapSet.union(one_four, five_eight)
#=> MapSet.new([1, 2, 3, 4, 5, 6, 7, 8])
disjoint?/2 asks whether two sets have no overlap. intersection/2 gives you the overlap. difference/2 gives you what is in the first set but not the second. union/2 gives you everything from both.
Those operations depend on the same question repeated many times: is this element present? That is where the map underneath matters. A list has to be walked. A map is built for lookup.
The Practical Rule
Here is the habit to build.
If you see this pattern, pause:
allowed_ids = [1, 2, 3, 5, 8, 13]
Enum.filter(users, fn user ->
Enum.member?(allowed_ids, user.id)
end)
That works, but every Enum.member?/2 call walks the list looking for a match. If you do that inside a loop, the cost repeats for every user.
Build the set once:
allowed_ids =
[1, 2, 3, 5, 8, 13]
|> MapSet.new()
Enum.filter(users, fn user ->
MapSet.member?(allowed_ids, user.id)
end)
Same question. Better data structure.
So the practical rule is simple. If you find yourself calling Enum.member? on a list inside a loop, you have written a set with the wrong data structure. Build a MapSet once and query it as many times as you like.
Keys Matter, Values Don’t
You can simulate the whole idea with a plain map:
%{}
|> Map.put(1, [])
|> Map.put(1, [])
#=> %{1 => []}
That is a set, badly disguised. The duplicate handling came for free, because a map can only hold one entry per key. The empty list is there because a map needs a value and this one has nothing useful to say.
Bruce says it plainly:
“In a map set, the keys matter and the values don’t.”
— Bruce Tate
Once you see that, maps, structs, and MapSets stop feeling like separate facts. They become one family of ideas. A map stores values under keys. A struct gives that map a name and a checked shape. A MapSet uses the keys so completely that the values fade into the background.
That shift matters because the next layer of Elixir will keep asking the same question in different forms: what shape should this data have so the code can say what it means?
📚 Learn Elixir Data Structures by Mental Model
This comes from our Learning Elixir course, where Bruce teaches the mental models behind production architecture decisions through real-world scenarios. Learn when maps, structs, and MapSets give your data the right shape for the question your code needs to ask. Available via monthly subscription -- try it for one month.
— Paulo & Bruce