This is a short piece on Elixir structs: what they are and how you can use pattern matching with structs to ensure valid states in your programs.

Defining a struct

Let's start by defining a struct, Foo.

defmodule Foo, do: defstruct [bar: "baz"]

We can quickly validate that this works as expected: without a default argument, bar is assigned the value "baz". Otherwise it is assigned the specified value.

iex> %Foo{}
%Foo{bar: "baz"}
iex> %Foo{bar: "nice"}
%Foo{bar: "nice"}

What is a struct?

Under the hood, an Elixir struct is really just a "bare" map, with a __struct__ key.

We can validate this a couple of ways:

  1. Key access works as if it were a map, for all the struct's fields and for __struct__.
iex> nice = %Foo{bar: "nice"}
%Foo{bar: "nice"}
iex> nice.__struct__
Foo
iex> nice.bar
"nice"
  1. is_map/1, Map.keys/1, and Map.values/1 all work and produce an output as if the input struct is a map.
iex> is_map?(nice)
true
iex> Map.keys(nice)
[:__struct__, :bar]
iex> Map.values(nice)
[Foo, "nice"]

And finally, we can read the Elixir source code to verify wthat a struct not only acts like a map, but is a map:

-type struct() :: #{'__struct__' := atom(), atom() => any()}.

The above code is in Erlang, where #{} is the syntax for maps.

A struct isn't just a regular map however. Notice that we called it "bare". This is because it doesn't implement any of the protocols that a regular map would:

You can't enumerate over a struct.

iex> foo = %Foo{}
iex> Enum.map(foo, fn x -> x end)
** (Protocol.UndefinedError) protocol Enumerable not implemented for %Foo{bar: "baz"} of type Foo (a struct)
iex> foo_map = %{__struct__: Foo, bar: "baz"}
iex> Enum.map(foo_map, fn x -> x end)
** (Protocol.UndefinedError) protocol Enumerable not implemented for %Foo{bar: "baz"} of type Foo (a struct)
iex> foo_map = %{bar: "baz"}
iex> Enum.map(foo_map, fn x -> x end)
[bar: "baz"]

You can't access fields with brackets.

iex> foo = %Foo{}
iex> foo[:bar]
** (UndefinedFunctionError) function Foo.fetch/2 is undefined (Foo does not implement the Access behaviour. If you are using get_in/put_in/update_in, you can specify the field to be accessed using Access.key!/1)
iex> foo_map = %{__struct__: Foo, bar: "baz"}
iex> foo_map[:bar]
** (UndefinedFunctionError) function Foo.fetch/2 is undefined (Foo does not implement the Access behaviour. If you are using get_in/put_in/update_in, you can specify the field to be accessed using Access.key!/1)
iex> foo_map = %{bar: "baz"}
iex> foo_map[:bar]
"baz"

You may have noticed for both of these protocols that the behavior for a struct and for a map with the __struct__ key was the same. This is because they will compile to the same thing.

Pattern matching with maps

When pattern matching a map, the right-hand side must contain all keys on the left side. But the right-hand side may contain other keys as well. Put succinctly, the intersection of key-value pairs between the left and right-hand side must be equal to the left-hand side.

iex> %{a: 1} = %{a: 1, b: 2}
%{a: 1, b: 2}
iex> %{a: 1} = %{b: 2}
** (MatchError) no match of right hand side value: %{b: 2}

This asymmetrical behavior is useful since you may have maps that contain multiple key-value pairs, and functions that only operate on a subset of those.

For example you may have a map representing a user: {name: "Fooby", email: "foo@bar.com, password: "verysecret"}, and a function validate_user/1 that pattern matches against a map with an email and a password key to check that the email is valid and that the password is secure enough. In this function, we don't care about the name and therefore don't require its presence.

defp validate_email(email) do
  # TODO
end

defp validate_password(password) do
  # TODO
end

def validate_user(%{email: email, password: password} = _user) do
  validate_email(email)
  validate_password(password)
end

Pattern matching with structs

Continuing with our previous example, we might instead of a map have a User struct. This is typically more maintainable, since we only have to define the contract of how a User should be defined in one place.

defmodule User, do: defstruct [:name, :email, :password]

Knowing that structs are just maps with the __struct__ key and that pattern matching with maps require the right-hand side to have all fields on the left-hand side, we can thus make type checks for valid users like so:

def validate_user(%{__struct__: User, email: email, password: password} = _user) do
  validate_email(email)
  validate_password(password)
end

And, again, since we know that structs are just maps with the __struct__ key we can shorten this to:

def validate_user(%User{} = user) do
  validate_email(user.email)
  validate_password(user.password)
end

Now, validate_user/1 is guaranteed to only be successful if a User struct is given as input. However, unlike the previous example using a map, we now also enforce that the user must have a name key.

Some iex output has been stripped for brevity. If you see any errors, please let me know at contact@skogsbrus.xyz