Pattern matching structs in Elixir
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:
- 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"
is_map/1
,Map.keys/1
, andMap.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