Elixir from the ground up

Structs: maps with a shape

A struct is a map with a fixed set of fields and a name. The name lets a function head match on the kind of thing it was given, which turns whole branches of code into patterns.

Part 10 of 12

Advanced20 min readBack to Courses

A map with a name and a shape

A struct is defined in a module with defstruct, listing each field and the value it starts with: defstruct name: "someone", role: :member, active: true. Building one looks like a map with the struct name in front: %User{name: "Ada"}. Fields you leave out take their default. The defstruct line is the single place the shape is declared: change it and every value of that type changes with it.

The shape is enforced. A struct has exactly the fields it was defined with, so naming a field that does not exist is a mistake the compiler reports rather than a value that quietly travels on — the difference between a record and a bag of keys. A struct also knows its own name, which is what makes matching possible. The name also means two structs with the same field names are still different kinds: a %User{} is not a %Book{}, and a pattern for one will not match the other.

Matching on the kind of thing it is

Because a struct names itself, a pattern can ask for that name: def label(%User{role: :admin}) runs only for admins, and def label(%User{active: false}) catches the ones that have left. Written as three heads of one function, the rules a record obeys read like a list of sentences, each one specific and short. The order of the heads matters, as it does for any function: the most specific rule goes first, and a head that matches every %User{} goes last. A pattern can match on more than one field at once, so %User{role: :admin, active: true} is a rule about admins who are still active.

Changing a field uses the same short form as a map, %{user | role: :admin}, and it keeps the struct a struct — the name and the other fields come along. In a real project those functions usually live in the same module as the struct they work on, so the shape and the rules about it sit together.

One catch in this sandbox: in a single-file program the compiler refuses a struct literal at the top level of the file that defines the struct. Building the values through functions inside the module works, which is how real projects are arranged anyway — the module owns its struct and hands out finished values.

A ticket, with rules about it

defmodule Ticket do
  defstruct title: "untitled", done: false, tags: []

  def new(title), do: %Ticket{title: title}
  def finish(t), do: %{t | done: true}
  def tag(t, name), do: %{t | tags: [name | t.tags]}

  def label(%Ticket{done: true, title: t}), do: "[x] " <> t
  def label(%Ticket{title: t}), do: "[ ] " <> t
end

run = Ticket.finish(Ticket.new("run"))
tickets = [Ticket.new("write"), run]
tickets = tickets ++ [Ticket.tag(Ticket.new("rest"), "home")]

Enum.each(tickets, fn t -> IO.puts(Ticket.label(t)) end)
IO.inspect(Enum.map(tickets, fn t -> t.done end))
IO.inspect(Enum.at(tickets, 2).tags)

Three users, three rules

Run it and check which user gets which label. Then add a fourth user built by a function of your own, and change the order of the label heads to see what changes.

Output
Click "Run" to execute your code

Give a field the default that is right for most values, and let the interesting cases be built by a named function: User.new("Ada"), User.admin("Ada"). A reader then sees the intent in the call instead of counting fields in a literal.

The struct name is a field too

A struct is a map with a hidden __struct__ key holding the module name. %User{name: "Ada"} is shorthand the compiler turns into a map with that key and the defaults filled in. Matching %User{} asks for that key, which is why a plain map with the same fields does not match, and why a struct can be told apart from every other map. The key is part of the value, so printing a struct shows the module name, and comparing two structs compares their names as well as their fields.

Because the name is part of the value, functions can be written for one kind of record and left alone by the rest. %User{role: :admin} is a pattern, not a filter: it matches only structs of that name whose role is that atom, and refuses everything else before the body runs.

A default is not a check

A default is only the value a field starts with; it is not a rule. defstruct age: 0 does not stop a caller from building a user with age -1, and it does not make the field required. If a field has rules, the rule belongs in the function that builds the value, where it can check the input and return an error or a corrected value.

The same is true of the struct name: it says what kind of thing this is, not what values are allowed inside it. A pattern can match %User{role: :admin}, but it cannot say that a role must be one of three atoms. Keep the shape in the struct and the rules in the functions that construct and change it.

A struct literal written at the top level of the file that defines the struct fails here; build values through a function in the module. %{user | unknown: 1} raises because the update form will not add fields, and user.unknown raises for the same reason. Map.put(user, :unknown, 1) does add the key, but the result is a struct with a field the definition never promised — prefer a named function.

Try these next

  • Define a struct with two fields and a function that builds it with defaults.
  • Add two function heads that match on a field, and check their order.
  • Update one field with %{value | field: new} and confirm the original is unchanged.

A library of books

Run it and check which books are marked read. Then add a book of your own, finish it, and print only the books by one author using the module function.

Output
Click "Run" to execute your code

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

What does defstruct name: "someone", active: true do?