Map

A map is a collection data structure. An entry is made up of a key and a value. As noted in the Map documentation:

Maps are the “go to” key-value data structure in Elixir.

https://hexdocs.pm/elixir/Map.html

The “key” of a map can be anything. However, it is most commonly a string or an atom.

When you receive a web POST with data, it will be represented as a Map with string keys. When you prepare a response structure for JSON, it will likely end up as a Map with atom keys.

Let’s look at each.

Make sure to try these things out in an IEx session.

String-Key Version

%{"name" => "Howard", "age" => 30}
#=> %{"age" => 30, "name" => "Howard"}

You may notice that the keys do not preserve order.

Atom-Key Version

%{name: "Howard", age: 30}
#=> %{age: 30, name: "Howard"}

An atom key can also be written using the arrow syntax. It is shortened for you.

%{:name => "Howard", :age => 30}
#=> %{age: 30, name: "Howard"}

Other Key Types

A map can have any data structure as a key and a value.

The key can be any data type.

%{
  1 => "Integer One",
  1.5 => "Float!",
  true => "boolean key",
  {:name, "Daniel"} => "a tuple",
  %{map_as_key: true} => "gracious!",
  [1, 2, 3] => "a list"
}

Using any data type as a key may not be very practical, but it can come in handy. Just know that this ability exists.

Again, the most common keys are atoms and strings.

Nested Values

It is common to have nested values. Take this for example:

order = %{
  id: 1,
  customer: %{id: 99, name: "Widgets, Inc."},
  item: %{
    id: "item-11332",
    name: "Sprocket #12",
    price: 12.70,
    quantity: 1
  },
  discounts: [%{code: "SUMMER19"}],
  total: 10.00
}

The customer, item, and discounts keys are atoms. The value is another data structure.

Accessing Values in a Map

There are a lot of ways to access the values and structure of a map. Here we’ll introduce only a few of the basics.

Map.get/3

We can access elements of the map using the Map.get/3 function. We provide it the map and the key we want the value for. The third argument is the default value to return if the key isn’t found in the map. It defaults to nil.

Map.get(order, :id)
#=> 1

Requesting a missing key from the map, we can give the default value we want back.

Map.get(order, :missing_key)
#=> nil

Map.get(order, :missing_key, "Hey! It's missing!")
#=> "Hey! It's missing!"

Access Behaviour

Another way to access the value of a key is to use the access operator [].

order[:id]
#=> 1

order[:missing_key]
#=> nil

When the key is an atom, it supports accessing it directly like this:

order.id
#=> 1

This will now cause an error if trying to access a missing key.

order.missing_key
#=> ** (KeyError) key :missing_key not found in: ...

Kernel.get_in/2

Another option is Kernel.get_in/2. It supports a handy way of returning data nested deeper in the structure.

get_in(order, [:customer, :name])
#=> "Widgets, Inc."

Changing an Immutable Map

Data structures in Elixir are immutable. Changing an element in a map returns a new map with the desired change. Internally, it is managed by pointers where everything points to the values of the old data structure with the exception of the latest change.

You don’t need to worry about the internals though. You can just know that it is efficient memory management and it ensures “immutable” data.

There are several ways to alter a map.

Map.put/3

The Map.put/3 function can add new keys to a map and change existing ones.

person = %{
  name: "Sally Green",
  age: 35,
  position: "Manager",
  division: "Engineering"
}

Say we want to add another key to track on the person. Now we want to track the corporate region she works in.

Map.put(person, :region, "west-1")
#=> %{
#=>   age: 35,
#=>   division: "Engineering",
#=>   name: "Sally Green",
#=>   position: "Manager",
#=>   region: "west-1"
#=> }

If we look at the person again, we see it is unchanged! It doesn’t contain the newly added region.

person
#=> %{
#=>   age: 35,
#=>   division: "Engineering",
#=>   name: "Sally Green",
#=>   position: "Manager"
#=> }

We didn’t modify the original person map. It added the key to a new map where the new map’s other keys all pointed back to the original person map.

If we want to keep the change, we can re-bind the person variable to point to the newly updated map. Alternatively we can create a new variable to bind to the altered map.

person = Map.put(person, :region, "west-1")
#=> %{
#=>   age: 35,
#=>   division: "Engineering",
#=>   name: "Sally Green",
#=>   position: "Manager",
#=>   region: "west-1"
#=> }

Now the person variable points to the updated map.

person
#=> %{
#=>   age: 35,
#=>   division: "Engineering",
#=>   name: "Sally Green",
#=>   position: "Manager",
#=>   region: "west-1"
#=> }
Thinking Tip: Re-Binding

In Elixir, we don’t call it “variable assignment” because we aren’t “assigning” a value to a variable. We are “binding” a variable to a value. When you think of how it’s working internally with pointers, this makes more sense.

Elixir allows us to re-bind a variable. Erlang and other functional programming languages don’t even allow that. Instead, I’d have to create a new variable for every change. Elixir makes this easier and it can deceptively feel like how it works in other languages.

Kernel.put_in/3

The Kernel.put_in/3 function lets us add new keys and update existing ones in a deeply nested map.

Updating a value deeper inside a nested map can be hairy when using Map.put/3 because you have to update the map at each level. Instead Kernel.put_in/3 gives us the syntax we need to make a deeper change.

Remember that we don’t need to use the module Kernel as that is imported for us.

In this example our data is nested 3 levels deep and we want to update the :value at the deepest level.

data = %{
  name: "level 1",
  value: 100,
  data_1: %{
    name: "level 2",
    value: 200,
    data_2: %{
      name: "level 3",
      value: 300
    }
  }
}

Using Kernel.put_in/3 works well. The second argument is a list of the keys to follow that get us where we want to go. The last argument is the new value to set for the last key in our list.

put_in(data, [:data_1, :data_2, :value], 3000)
#=> %{
#=>   data_1: %{
#=>     data_2: %{name: "level 3", value: 3000},
#=>     name: "level 2",
#=>     value: 200
#=>   },
#=>   name: "level 1",
#=>   value: 100
#=> }

This updated the value at the deepest level and returned a new map with the desired change. Data in Elixir is immutable. Our original data variable is still bound to the original map with the unchanged value.

Working with immutable data takes some time to get used to, but it is an important foundation for so many benefits that we want! The benefits are worth the initial discomfort we feel with it.

Special Update Syntax

Maps support a special update syntax that can update existing keys only. Here is how to update a map with atom keys.

%{person | position: "VP of Engineering", age: 36}
#=> %{
#=>   age: 36,
#=>   division: "Engineering",
#=>   name: "Sally Green",
#=>   position: "VP of Engineering",
#=>   region: "west-1"
#=> }

This can set multiple values at one time. This update recognized Sally’s promotion and that she is now older. Likewise, it doesn’t update the person unless we explicitly re-bind the person variable to the updated map.

person = %{person | position: "VP of Engineering", age: 36}

This only works for keys that already exist on the map. It will error when attempting to set a key that doesn’t exist.

%{person | salary: 100_000}
#=> ** (KeyError) key :salary not found in: ...

Here is how to update a map with string keys.

string_person = %{"name" => "Timothy", "occupation" => "Teacher Level 1", "age" => 31}
%{string_person | "occupation" => "Teacher Level 2", "age" => 32}

Try performing some of your own updates in an IEx session. What do you have to do to keep the changes?

More Resources

For more detailed information on the built-in functionality for working with maps, refer to these resources:

1 Comments

  1. Akan Udo on July 28, 2023 at 3:27 am

    I’m liking this Map stuff now

Leave a Comment

You must be logged in to post a comment.