Elixir & Erlang/OTP Module 5 – Recursion, collections and data modelling
Structs and data modelling
Define Elixir structs with defaults and enforced keys, let the compiler check their fields, take JSON in and out safely, and pick the right shape for data.
What you will learn
- Define a struct with defaults and enforced keys, and predict what building one with a missing or unknown key does
- Read, update and match structs, and say which slips the compiler catches and which only the running program catches
- Build structs from JSON through an allowlist with struct!/2, and keep private fields out of JSON with @derive
- Choose a struct, a plain map, an atom or a tagged tuple for each piece of domain data
Before you start
What you’ll learn · Before you start
What you will learn
- Define a struct with defaults and enforced keys, and predict what building one with a missing or unknown key does
- Read, update and match structs, and say which slips the compiler catches and which only the running program catches
- Build structs from JSON through an allowlist with struct!/2, and keep private fields out of JSON with @derive
- Choose a struct, a plain map, an atom or a tagged tuple for each piece of domain data
Before you start
On this page
A plain map takes any key, so %{titel: "Gora", author: "Rabindranath Tagore"} is a perfectly good book until a
label printer reads book[:title] and prints “ by Rabindranath Tagore”, with no title and no error. A
struct: A map with an extra __struct__ key that names the module defining it, and a fixed set of keys declared with defstruct. A struct literal with an unknown key stops the compile of the module that contains it. is a map whose keys are fixed when its module compiles, so the same slip stops the
compile before one label is printed.
Below, you will define a library’s books and loans as structs, see which slips the compiler, the type checker of Elixir 1.20 and the running program each catch, update and match structs, take JSON in and send it out without losing or leaking fields, and choose a struct, a map, an atom or a tagged tuple for each piece of data. Every output shown here comes from runs on Elixir 1.20.4 with Erlang/OTP 29.1.1.
A struct is a map whose keys are fixed when its module compiles
defstruct inside a module defines a struct that takes the module’s name. Its argument lists the fields: a field
given as an atom alone starts as nil, and fields with a default come last, as a keyword list. book.exs defines a
Book and builds three:
# A book of a college library, as a struct.
defmodule Book do
# Fields without a default start as nil; fields with one come last.
defstruct [:title, :author, copies: 1]
end
defmodule Main do
def run do
gora = %Book{title: "Gora", author: "Rabindranath Tagore"}
IO.inspect(gora)
IO.inspect(%Book{}, label: "no fields given")
IO.inspect(%Book{title: "Godaan", copies: 3}, label: "copies given")
IO.inspect(is_map(gora), label: "is_map(gora)")
IO.inspect(gora.__struct__, label: "gora.__struct__")
IO.inspect(map_size(gora), label: "map_size(gora)")
end
end
Main.run() from session import run
run("""
elixir book.exs
""") Output
$ elixir book.exs
%Book{title: "Gora", author: "Rabindranath Tagore", copies: 1}
no fields given: %Book{title: nil, author: nil, copies: 1}
copies given: %Book{title: "Godaan", author: nil, copies: 3}
is_map(gora): true
gora.__struct__: Book
map_size(gora): 4
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 book_session.py
%Book{} with no fields gave title and author as nil and copies as its default, 1. is_map/1 said true:
the structs guide calls structs “simply maps with a ‘special’ field named __struct__ that holds the name of the
struct”, which is why map_size/1 counted four keys for three fields. A struct is that map plus a promise about its
keys.
The promise is checked early. A struct literal such as %Book{…} is expanded while the module that contains it
compiles, and a key the struct does not have stops the compile. Here title is misspelt in Main.run/0, and the
same slip is made in a plain map that a function reads with brackets:
# One letter wrong in a struct literal.
IO.puts("the script starts")
defmodule Book do
defstruct [:title, :author, copies: 1]
end
defmodule Main do
def run, do: %Book{titel: "Gora", author: "Rabindranath Tagore"}
end
Main.run()
IO.puts("the script ends") # The same letter wrong in a plain map, read by code that cannot know its keys.
defmodule Shelf do
def label(book), do: "#{book[:title]} by #{book[:author]}"
end
defmodule Main do
def run do
gora = %{titel: "Gora", author: "Rabindranath Tagore"}
IO.puts("label: " <> Shelf.label(gora))
end
end
Main.run() from session import run
# The same slip of the pen in a struct literal and in a plain map.
run("""
!elixir book_typo.exs
elixir shelf_label.exs
""") Output
$ elixir book_typo.exs
the script starts
** (KeyError) key :titel not found
expanding struct: Book.__struct__/1
book_typo.exs:9: Main.run/0
[exit status 1]
$ elixir shelf_label.exs
label: by Rabindranath Tagore
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 typo_session.py
The struct script printed its first line and stopped with KeyError, “key :titel not found”, while “expanding
struct: Book.struct/1”. The map script ran to the end and printed a label with no title, because book[:title]
on a map without that key is nil. Step through what the compiler did with the struct:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The script starts. The first line of book_typo.exs runs and prints "the script starts". Then defmodule Book compiles, and Book now knows its three keys.
- Main is compiled. Next, defmodule Main is compiled. Inside run/0 the compiler meets the literal %Book{titel: "Gora", …}, and a struct literal is expanded now, while Main compiles, not later when run/0 is called.
- It asks Book for its keys. To expand the literal, the compiler calls Book.__struct__/1, the function defstruct generated, with the keys the literal gives.
- titel is not a key. Book has title, author and copies, and no titel, so the call raises KeyError, "key :titel not found", which the compiler reports while "expanding struct: Book.__struct__/1".
- Nothing else runs. The compile of Main has failed, so run/0 never exists and "the script ends" is never printed. The same slip in a plain map compiles and runs, and shelf_label.exs printed a label with no title.
Key idea
A struct is a map with a __struct__ key and a fixed set of keys, and every struct literal is checked against those
keys when its module compiles, so a misspelt key never reaches a running program.
Checkpoint
Check: A struct's fixed keys
Enforced keys must be given when a struct is built, and nothing more is checked
A loan without a member makes no sense, so Loan lists book_id and member_id in @enforce_keys, the module
attribute that defstruct reads for the keys that must be given. Each enforced key: A key listed in @enforce_keys before defstruct: a struct literal or struct!/2 without it raises ArgumentError. Updates are not checked, and nil counts as a value. is
checked whenever a %Loan{…} literal is expanded:
# A loan needs a book and a member; @enforce_keys checks that both are given.
defmodule Loan do
@enforce_keys [:book_id, :member_id]
defstruct [:book_id, :member_id, :due, status: :active]
end
defmodule Main do
def run do
loan = %Loan{book_id: 42, member_id: 7}
IO.inspect(loan)
IO.inspect(%Loan{book_id: nil, member_id: nil}, label: "both nil")
IO.inspect(%{loan | member_id: nil}, label: "updated to nil")
IO.inspect(struct(Loan, book_id: 42), label: "struct/2, no member")
end
end
Main.run() # A loan literal without one of the enforced keys.
defmodule Loan do
@enforce_keys [:book_id, :member_id]
defstruct [:book_id, :member_id, :due, status: :active]
end
defmodule Main do
def run, do: %Loan{book_id: 42}
end
Main.run() from session import run
# What @enforce_keys lets through, then a literal that leaves an enforced key out.
run("""
elixir loan.exs
!elixir loan_missing.exs
""") Output
$ elixir loan.exs
%Loan{book_id: 42, member_id: 7, due: nil, status: :active}
both nil: %Loan{book_id: nil, member_id: nil, due: nil, status: :active}
updated to nil: %Loan{book_id: 42, member_id: nil, due: nil, status: :active}
struct/2, no member: %Loan{book_id: 42, member_id: nil, due: nil, status: :active}
$ elixir loan_missing.exs
** (ArgumentError) the following keys must also be given when building struct Loan: [:member_id]
expanding struct: Loan.__struct__/1
loan_missing.exs:8: Main.run/0
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 enforce_session.py
Leaving member_id out of a literal stopped the compile with ArgumentError, “the following keys must also be given
when building struct Loan: [:member_id]”. Everything else in loan.exs went through:
What loan.exs did |
The result |
|---|---|
%Loan{book_id: 42, member_id: 7} |
built, with due: nil and status: :active |
%Loan{book_id: nil, member_id: nil} |
built: nil counts as a value |
an update that sets member_id to nil |
updated, nothing checked |
struct(Loan, book_id: 42) |
built, with member_id: nil |
The Kernel documentation says it plainly: @enforce_keys “is not enforced on updates and it does not provide any
sort of value-validation”. struct/2, which builds a struct from a list of fields while the program runs, skips the
check as well; its stricter twin struct!/2 makes it, as the section on JSON shows. So enforced keys guard against a
field forgotten in code, and the values need a check of their own, in the one function that builds every loan:
# @enforce_keys checks that keys are given, never their values, so Loan.new/3
# checks the values and is the one place the rest of the code builds loans.
defmodule Loan do
@enforce_keys [:book_id, :member_id, :due]
defstruct [:book_id, :member_id, :due, status: :active]
def new(book_id, member_id, %Date{} = due)
when is_integer(book_id) and book_id > 0 and is_integer(member_id) and member_id > 0 do
{:ok, %Loan{book_id: book_id, member_id: member_id, due: due}}
end
def new(_book_id, _member_id, _due), do: {:error, :invalid_loan}
end
defmodule Main do
def run do
IO.inspect(Loan.new(42, 7, ~D[2026-11-02]))
IO.inspect(Loan.new(42, nil, ~D[2026-11-02]))
IO.inspect(Loan.new(42, 7, "2026-11-02"))
end
end
Main.run() from session import run
run("""
elixir loan_new.exs
""") Output
$ elixir loan_new.exs
{:ok, %Loan{book_id: 42, member_id: 7, due: ~D[2026-11-02], status: :active}}
{:error, :invalid_loan}
{:error, :invalid_loan}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 new_session.py
Loan.new/3 answered {:ok, loan} for two positive whole-number ids and a Date, and {:error, :invalid_loan} for
a nil member or a date written as a string. Code that builds every loan through it cannot make one without a member.
Trusting @enforce_keys to check a form
%Loan{book_id: params["book"], member_id: params["member"]} compiles, because both keys are given, and builds a
loan with nil in them when the form leaves a field out. Check the values in a function such as Loan.new/3, and
build loans only through it.
Checkpoint
Check: Enforced keys
Read a field with a dot, and update with the map syntax
A struct shares the map syntax for reading and updating fields, but not the protocols that maps implement: the structs
guide says structs “do not inherit any of the built-in features that maps do”. read_update.exs reads a field,
updates one and tries two things that work on maps:
# Reading and updating a struct, and two things maps do that structs do not.
defmodule Loan do
@enforce_keys [:book_id, :member_id]
defstruct [:book_id, :member_id, :due, status: :active]
end
defmodule Main do
def run do
loan = %Loan{book_id: 42, member_id: 7, due: ~D[2026-11-02]}
IO.inspect(loan.due, label: "loan.due")
IO.inspect(Map.get(loan, :status), label: "Map.get(loan, :status)")
returned = %{loan | status: :returned}
IO.inspect(returned.status, label: "returned.status")
IO.inspect(loan.status, label: "loan.status, unchanged")
attempt("loan[:status]", fn -> loan[:status] end)
attempt("Enum.count(loan)", fn -> Enum.count(loan) end)
end
defp attempt(code, fun) do
fun.()
rescue
error -> IO.puts("#{code} raised #{inspect(error.__struct__)}:\n" <> Exception.message(error))
end
end
Main.run() from session import run
run("""
elixir read_update.exs
""") Output
$ elixir read_update.exs
loan.due: ~D[2026-11-02]
Map.get(loan, :status): :active
returned.status: :returned
loan.status, unchanged: :active
loan[:status] raised UndefinedFunctionError:
function Loan.fetch/2 is undefined (Loan does not implement the Access behaviour
You can use the "struct.field" syntax to access struct fields. You can also use Access.key!/1 to access struct fields dynamically inside get_in/put_in/update_in). Make sure the module name is correct and has been specified in full (or that an alias has been defined)
Enum.count(loan) raised Protocol.UndefinedError:
protocol Enumerable not implemented for Loan (a struct)
Got value:
%Loan{book_id: 42, member_id: 7, due: ~D[2026-11-02], status: :active}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 read_session.py
loan.due and Map.get/2 read fields. %{loan | status: :returned} made a new loan with one field changed and left
loan as it was, since data in Elixir never changes in place; a key the struct does not have would raise KeyError
instead of being added. loan[:status] raised UndefinedFunctionError: the bracket syntax goes through the Access
behaviour, which Loan does not implement, and the message suggests the struct.field syntax instead. Enum.count/1 raised
Protocol.UndefinedError, because a struct is enumerable only when its module implements Enumerable, which the
next lesson does for a struct of its own.
A field whose name is in a variable
Map.get(loan, field) reads a field named by a variable. Inside get_in/2 and its relatives, the message’s own
suggestion, Access.key!/1, does the same and raises for a key the struct does not have.
Checkpoint
Check: Reading and updating a struct
Match %Loan where a value enters, and the compiler checks every field
Elixir 1.20’s type checker follows what each pattern says about a value. A function head that matches
%Loan{} = loan tells it that loan is a Loan, so it knows every key. Each function of loans.ex has one slip of
the pen:
# Three functions, each with one slip of the pen.
defmodule Loans do
# loan is matched as a Loan, so the compiler knows its keys.
def overdue?(%Loan{} = loan, today), do: Date.before?(loan.due_on, today)
# The same in an update: stauts for status.
def return(%Loan{} = loan), do: %{loan | stauts: :returned}
# Here loan could be any value, so nothing is known about its keys.
def due(loan), do: loan.due_on
end from session import run
# Compile the two files, then call the function the compiler could not check.
run("""
cd checks
elixirc -o out loan.ex loans.ex
!elixir -pa out -e 'Loans.due(%Loan{book_id: 42, member_id: 7, due: ~D[2026-11-02]})'
""") Output
$ cd checks
$ elixirc -o out loan.ex loans.ex
warning: expected a map with key :stauts in map update syntax:
%{loan | stauts: :returned}
but got type:
dynamic(%Loan{book_id: term(), member_id: term(), due: term(), status: term()})
where "loan" was given the type:
# type: dynamic(%Loan{})
# from: loans.ex:7:22
%Loan{} = loan
type warning found at:
│
7 │ def return(%Loan{} = loan), do: %{loan | stauts: :returned}
│ ~
│
└─ loans.ex:7:35: Loans.return/1
warning: unknown key .due_on in expression:
loan.due_on
the given type does not have the given key:
dynamic(%Loan{book_id: term(), member_id: term(), due: term(), status: term()})
where "loan" was given the type:
# type: dynamic(%Loan{})
# from: loans.ex:4:24
%Loan{} = loan
type warning found at:
│
4 │ def overdue?(%Loan{} = loan, today), do: Date.before?(loan.due_on, today)
│ ~~~~~~
│
└─ loans.ex:4:62: Loans.overdue?/2
$ elixir -pa out -e 'Loans.due(%Loan{book_id: 42, member_id: 7, due: ~D[2026-11-02]})'
** (KeyError) key :due_on not found in:
%Loan{book_id: 42, member_id: 7, due: ~D[2026-11-02], status: :active}
Did you mean:
* :due
loans.ex:10: Loans.due/1
(stdlib 8.1) erl_eval.erl:1048: :erl_eval.do_apply/7
(elixir 1.20.4) lib/code.ex:649: Code.validated_eval_string/4
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 checks_session.py
The compile printed two warnings before anything ran: .due_on is an “unknown key” of the %Loan{} that
overdue?/2 matched, and the update in return/1 “expected a map with key :stauts”. due/1 takes any value, so
the compiler had nothing to check, and the slip waited for the call: KeyError, with “Did you mean: :due”. Together
with the scripts of the first section, that makes a ladder:
| The slip | Where it shows up |
|---|---|
%Book{titel: …}, a struct literal |
the compile stops with KeyError |
loan.due_on after %Loan{} = loan |
a warning when the module compiles |
an update with stauts: after %Loan{} = loan |
a warning when the module compiles |
loan.due_on on a value of any type |
KeyError when the line runs |
book[:title] on a plain map |
nowhere: the value is nil |
Key idea
Match %Loan{} where a value enters a function: the compiler then knows its keys and warns about every misspelt
field, and a value of the wrong type fails at the door instead of deep inside.
Elixir has a second update syntax that names the struct, %Loan{loan | status: :returned}. On Elixir 1.20.4 its only
check is the type checker’s. return.ex passes a book to return/1 by mistake; the session compiles and runs it on
Elixir 1.20.4, then on Elixir 1.19.6, then fixes the function:
# The struct update syntax names the struct: %Loan{loan | ...}.
defmodule Book do
defstruct [:title, status: :on_shelf]
end
defmodule Loan do
@enforce_keys [:book_id, :member_id]
defstruct [:book_id, :member_id, :due, status: :active]
def return(loan), do: %Loan{loan | status: :returned}
end
defmodule Main do
def run do
IO.inspect(Loan.return(%Loan{book_id: 42, member_id: 7}), label: "a loan")
IO.inspect(Loan.return(%Book{title: "Gora"}), label: "a book by mistake")
end
end from session import run, use
# The struct update syntax compiled and run by Elixir 1.20.4, then by Elixir 1.19.6, then the fixed function.
run("""
cd update
elixirc -o new return.ex
elixir -pa new -e 'Main.run()'
""")
use(erlang="28.5.0.7", elixir="1.19.6-otp-28")
run("""
elixir -e 'IO.puts("Elixir " <> System.version())'
elixirc -o old return.ex
!elixir -pa old -e 'Main.run()'
""")
use()
run("""
elixirc -o fixed return_fixed.ex
!elixir -pa fixed -e 'Main.run()'
""") Output
$ cd update
$ elixirc -o new return.ex
warning: a struct for Loan is expected on struct update:
%Loan{loan | status: :returned}
but got type:
dynamic()
where "loan" was given the type:
# type: dynamic()
# from: return.ex:10:14
loan
when defining the variable "loan", you must also pattern match on "%Loan{}"
type warning found at:
│
10 │ def return(loan), do: %Loan{loan | status: :returned}
│ ~
│
└─ return.ex:10:25: Loan.return/1
$ elixir -pa new -e 'Main.run()'
a loan: %Loan{book_id: 42, member_id: 7, due: nil, status: :returned}
a book by mistake: %Book{title: "Gora", status: :returned}
$ elixir -e 'IO.puts("Elixir " <> System.version())'
Elixir 1.19.6
$ elixirc -o old return.ex
warning: a struct for Loan is expected on struct update:
%Loan{loan | status: :returned}
but got type:
dynamic()
where "loan" was given the type:
# type: dynamic()
# from: return.ex:10:14
loan
when defining the variable "loan", you must also pattern match on "%Loan{}".
hint: given pattern matching is enough to catch typing errors, you may optionally convert the struct update into a map update. For example, instead of:
user = some_function()
%User{user | name: "John Doe"}
it is enough to write:
%User{} = user = some_function()
%{user | name: "John Doe"}
typing violation found at:
│
10 │ def return(loan), do: %Loan{loan | status: :returned}
│ ~
│
└─ return.ex:10:25: Loan.return/1
$ elixir -pa old -e 'Main.run()'
a loan: %Loan{book_id: 42, member_id: 7, due: nil, status: :returned}
** (BadStructError) expected a struct named Loan, got:
%Book{title: "Gora", status: :on_shelf}
return.ex:10: Loan.return/1
return.ex:16: Main.run/0
nofile:1: (file)
(stdlib 7.3.0.3) erl_eval.erl:924: :erl_eval.do_apply/7
(elixir 1.19.6) lib/code.ex:629: Code.validated_eval_string/3
[exit status 1]
$ elixirc -o fixed return_fixed.ex
warning: incompatible types given to Loan.return/1:
Loan.return(%Book{..., title: "Gora"})
given types:
-%Book{title: binary(), status: :on_shelf}-
but expected one of:
%Loan{}
type warning found at:
│
16 │ IO.inspect(Loan.return(%Book{title: "Gora"}), label: "a book by mistake")
│ ~
│
└─ return_fixed.ex:16:21: Main.run/0
$ elixir -pa fixed -e 'Main.run()'
a loan: %Loan{book_id: 42, member_id: 7, due: nil, status: :returned}
** (FunctionClauseError) no function clause matching in Loan.return/1
The following arguments were given to Loan.return/1:
# 1
%Book{title: "Gora", status: :on_shelf}
Attempted function clauses (showing 1 out of 1):
def return(-%Loan{} = loan-)
return_fixed.ex:10: Loan.return/1
return_fixed.ex:16: Main.run/0
(stdlib 8.1) erl_eval.erl:1048: :erl_eval.do_apply/7
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 update_session.py
Both versions warned that “a struct for Loan is expected on struct update”. Run on 1.20.4, return/1 handed back
the book it was given, with its status changed to :returned; on 1.19.6 the same call raised BadStructError. The
gradual typing guide explains the warning: by design, a struct update “must be statically proven”, and the way to
prove it is to match the struct where the variable is defined. return_fixed.ex does that in the head,
return(%Loan{} = loan): the compiler then pointed at the call that passes a Book, and the call raised
FunctionClauseError instead of returning a wrong value.
Elixir 1.20: struct updates are checked when the code compiles
In these runs Elixir 1.19.6 checked %Loan{loan | …} when the code ran and Elixir 1.20.4 did not. Match
%Loan{} = loan where a value enters a function; then, as the 1.19.6 hint says, the plain %{loan | …} update is
enough.
Checkpoint
Check: What the compiler checks
A struct pattern checks the type, a map pattern only the keys
Patterns on structs choose code by the kind of value: %Loan{} matches only loans, a map pattern such as
%{status: status} matches any map with that key, loans included, %name{} matches any struct and binds its module to
name, and in a guard is_struct(value, Loan) asks what %Loan{} asks. The library desk describes four values:
# The library desk describes whatever it is handed, one clause per kind of value.
defmodule Book do
defstruct [:title, copies: 1]
end
defmodule Loan do
@enforce_keys [:book_id, :member_id]
defstruct [:book_id, :member_id, :due, status: :active]
end
defmodule Desk do
def describe(%Loan{status: :returned}), do: "a returned loan"
def describe(%Loan{member_id: id}), do: "a loan to member #{id}"
def describe(%name{}), do: "another struct, #{inspect(name)}"
def describe(%{status: status}), do: "a plain map with status #{inspect(status)}"
end
defmodule Main do
def run do
returned = %Loan{book_id: 42, member_id: 7, status: :returned}
active = %Loan{book_id: 42, member_id: 9}
plain = %{book_id: 42, member_id: 9, status: :active}
for value <- [returned, active, %Book{title: "Gora"}, plain] do
IO.puts(Desk.describe(value))
end
IO.inspect(match?(%{status: :active}, active), label: "map pattern, a Loan")
IO.inspect(is_struct(active, Loan), label: "is_struct(active, Loan)")
end
end
Main.run() Follow the four values through the clauses, tried from the top, before the run confirms them:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A returned loan. The returned loan is a Loan and its status is :returned, so the first clause matches and describe/1 answers "a returned loan". No other clause is tried.
- An active loan. The active loan is a Loan, but its status is :active, so the first clause does not match. The second asks only for a Loan, and binds its member_id, 9, to id.
- A book. A Book is not a Loan, so both %Loan{} clauses fail, whatever its fields. %name{} matches any struct and binds the module, Book, to name.
- A plain map. The plain map has every key a loan has, but no __struct__ key, so neither %Loan{} clause matches, and %name{} needs a struct. The last clause needs only a map with a status key.
- The order decides. Clauses are tried from the top. Put %name{} first and it takes every struct; Elixir 1.20 then reports both %Loan{} clauses below it as redundant, as desk_order.ex showed.
from session import run
# Four values through the clauses of Desk.describe/1, then the clauses in the wrong order.
run("""
elixir desk.exs
cd desk
elixirc -o out desk_order.ex
""") Output
$ elixir desk.exs
a returned loan
a loan to member 9
another struct, Book
a plain map with status :active
map pattern, a Loan: true
is_struct(active, Loan): true
$ cd desk
$ elixirc -o out desk_order.ex
warning: the following clause is redundant:
def describe(%Loan{status: :returned})
it has type:
%Loan{status: :returned}
previous clauses have already matched on the following types:
%{..., __struct__: atom()}
│
9 │ def describe(%Loan{status: :returned}), do: "a returned loan"
│ ~
│
└─ desk_order.ex:9:7: Desk.describe/1
warning: the following clause is redundant:
def describe(%Loan{member_id: id})
it has type:
dynamic(%Loan{})
previous clauses have already matched on the following types:
%{..., __struct__: atom()}
│
10 │ def describe(%Loan{member_id: id}), do: "a loan to member #{id}"
│ ~
│
└─ desk_order.ex:10:7: Desk.describe/1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 desk_session.py
The returned loan stopped at the first clause and the active one at the second. The book failed both %Loan{} clauses
and stopped at %name{}, with name bound to Book. The plain map has every key a loan has, yet it matched no struct
clause and reached the last one, because a struct pattern checks __struct__. The other way round, the map pattern
%{status: :active} matched the active loan: a loan is a map. In desk_order.ex the %name{} clause comes first and
takes every struct, so Elixir 1.20 reported both %Loan{} clauses below it as redundant.
The most specific clause first
Order clauses from the narrowest to the widest: %Loan{status: :returned} before %Loan{}, and %name{} or
%_{} last. A plain map pattern placed above them would take every struct that has its keys.
Checkpoint
Check: Struct and map patterns
JSON comes in through an allowlist and goes out through only:
Data from outside the program, such as a JSON body, has string keys, and a struct has atom keys. JSON.decode!/1 turns
{"id": 7, …} into a map with the key "id". Kernel has two functions that build a struct from such data while the
program runs, and they treat it very differently. member_in.exs tries both on one sign-up body:
# A sign-up form arrives as JSON, so its keys are strings.
defmodule Member do
@enforce_keys [:id, :name]
defstruct [:id, :name, :email, :pin_hash, role: :reader]
# The fields a client may send, by their JSON names. role and pin_hash are not
# among them, so a client can never set them.
@allowed %{"id" => :id, "name" => :name, "email" => :email}
def from_json(params) when is_map(params) do
fields = for {key, value} <- params, Map.has_key?(@allowed, key), do: {@allowed[key], value}
struct!(Member, fields)
end
end
defmodule Main do
def run do
body = ~s({"id": 7, "name": "Asha", "email": "asha@example.com", "role": "admin"})
params = JSON.decode!(body)
IO.inspect(params, label: "decoded")
IO.inspect(struct(Member, params), label: "struct/2")
attempt("struct!/2", fn -> struct!(Member, params) end)
IO.inspect(Member.from_json(params), label: "from_json")
attempt("from_json without a name", fn -> Member.from_json(%{"id" => 8}) end)
end
defp attempt(what, fun) do
fun.()
rescue
error -> IO.puts("#{what} raised #{inspect(error.__struct__)}: #{Exception.message(error)}")
end
end
Main.run() from session import run
run("""
elixir member_in.exs
""") Output
$ elixir member_in.exs
decoded: %{"email" => "asha@example.com", "id" => 7, "name" => "Asha", "role" => "admin"}
struct/2: %Member{id: nil, name: nil, email: nil, pin_hash: nil, role: :reader}
struct!/2 raised KeyError: key "email" not found
from_json: %Member{
id: 7,
name: "Asha",
email: "asha@example.com",
pin_hash: nil,
role: :reader
}
from_json without a name raised ArgumentError: the following keys must also be given when building struct Member: [:name]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 in_session.py
struct(Member, params) returned a member with nothing from the body: id, name and email were nil, and role
kept its default. The Kernel documentation says keys “that don’t exist in the struct are automatically discarded”, and
a string key never exists in a struct, so all four went, with no error and no check of the enforced keys. struct!/2
checks every key, and raised KeyError for "email". Member.from_json/1 keeps an
allowlist: A list of what is allowed, such as the JSON fields a client may set: anything not on it is dropped or refused, instead of trying to list everything that is forbidden. of the JSON names a client may send and passes only those, as atom fields, to
struct!/2. "role" is not on it, so a client cannot make itself an :admin, and a body
without a name failed with the enforced-key error instead of building a nameless member.
Never let a request choose the fields
Binding request fields straight onto your data is what OWASP calls a mass assignment vulnerability, and its cheat
sheet says to “explicitly allow-list fields”. Map the allowed names to atoms yourself, as @allowed does:
String.to_atom/1 on input fills the atom table, as the lesson on atoms showed.
Going out, JSON.encode!/1 encodes a struct only when the struct implements the JSON.Encoder protocol, and
@derive: An attribute set before defstruct that asks a protocol for an implementation for the struct, such as @derive {Inspect, only: [:name]}. A protocol that cannot generate one points the struct at its Any implementation. More in “Protocols: polymorphism over data” before defstruct asks the protocol for an implementation. Member derives it
with only: [:id, :name]:
# Only the fields listed in only: leave in JSON, whatever fields are added later.
defmodule Member do
@derive {JSON.Encoder, only: [:id, :name]}
@enforce_keys [:id, :name]
defstruct [:id, :name, :email, :pin_hash, role: :reader]
end
defmodule Book do
defstruct [:title, copies: 1]
end
defmodule Main do
def run do
asha = %Member{id: 7, name: "Asha", email: "asha@example.com", pin_hash: "9f2c41d8"}
IO.puts("member: " <> JSON.encode!(asha))
IO.puts("from_struct: " <> JSON.encode!(Map.from_struct(asha)))
try do
JSON.encode!(%Book{title: "Gora"})
rescue
error -> IO.puts("book: " <> (error |> Exception.message() |> String.split("\n") |> hd()))
end
end
end
Main.run() from session import run
run("""
elixir member_out.exs
""") Output
$ elixir member_out.exs
member: {"id":7,"name":"Asha"}
from_struct: {"id":7,"name":"Asha","email":"asha@example.com","pin_hash":"9f2c41d8","role":"reader"}
book: protocol JSON.Encoder not implemented for Book (a struct), the protocol must be explicitly implemented.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 out_session.py
The member went out as {"id":7,"name":"Asha"}: the email, the PIN hash and the role stayed in, and so will any
field added later, which is why the JSON.Encoder documentation says “Prefer using :only”. The second line is how
fields leak anyway: Map.from_struct/1 made a plain map, which has no derive, and every field went out. The book,
whose struct derives nothing, raised Protocol.UndefinedError instead of going out with whatever it holds. Step
through the whole journey:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- The body arrives. A sign-up request carries id, name, email and a role the client chose itself, "admin".
- Decoded, with string keys. JSON.decode!/1 gives a map whose keys are the strings "id", "name", "email" and "role". A struct has atom keys, so these still have to be mapped onto fields.
- A shortcut that drops it all. struct(Member, params) discards every key the struct does not have, and a string key is never one of them, so it returned a member with only its defaults: id, name and email nil, and no error.
- Only the allowed fields. Member.from_json/1 maps each allowed JSON name to its field: "id" to :id, "name" to :name, "email" to :email. "role" is not on the list, so the client cannot set it.
- Checked as it is built. struct!/2 checks every key against the struct and enforces @enforce_keys, so a body without a name raised ArgumentError instead of building a nameless member.
- The member. The member has the fields the client may set, and role keeps its default, :reader.
- Going out with only:. Member derives JSON.Encoder with only: [:id, :name], so the response is {"id":7,"name":"Asha"}. The email, the PIN hash and the role stay in, and so does any field added later.
- The shortcut that leaks. Map.from_struct/1 turns the member into a plain map, which has no derive, and JSON encodes every key of a plain map: the email, the PIN hash and the role went out.
Checkpoint
Check: JSON in and out
Give each piece of data the shape that fits it
A struct is one of four shapes the library’s data takes. The design anti-patterns guide names two mistakes that the choice avoids: primitive obsession, when basic types are “excessively used to carry structured information”, and boolean obsession, when “booleans are used instead of atoms to encode information”. Here is the second, in fines:
# Two ways to record what happened to a loan, and the fine each one charges.
# Fines are whole paise, 200 a day late, so no float rounding creeps in.
defmodule FlagLoan do
# Two booleans allow four combinations, and one of them means nothing.
defstruct [:due, returned?: false, lost?: false]
def fine(%FlagLoan{lost?: true}, _today), do: 50_000
def fine(%FlagLoan{returned?: true}, _today), do: 0
def fine(%FlagLoan{due: due}, today), do: max(Date.diff(today, due), 0) * 200
end
defmodule Loan do
# One atom, one state: a loan is :active, :returned or :lost, never two at once.
@enforce_keys [:due]
defstruct [:due, status: :active]
def fine(%Loan{status: :lost}, _today), do: 50_000
def fine(%Loan{status: :returned}, _today), do: 0
def fine(%Loan{status: :active, due: due}, today), do: max(Date.diff(today, due), 0) * 200
end
defmodule Main do
def run do
today = ~D[2026-11-09]
IO.inspect(Loan.fine(%Loan{due: ~D[2026-11-02]}, today), label: "active, 7 days late")
IO.inspect(Loan.fine(%Loan{due: ~D[2026-11-02], status: :returned}, today), label: "returned")
# Returned, and then marked lost by a second screen that never cleared returned?:
both = %FlagLoan{due: ~D[2026-11-02], returned?: true, lost?: true}
IO.inspect(FlagLoan.fine(both, today), label: "returned? and lost? both true")
end
end
Main.run() from session import run
run("""
elixir fines.exs
""") Output
$ elixir fines.exs active, 7 days late: 1400 returned: 0 returned? and lost? both true: 50000
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 model_session.py
With two booleans a loan has four possible states, and one of them, returned and lost at once, means nothing. A
second screen that set lost? without clearing returned? made FlagLoan charge the 50,000 paise fine for a lost
book on a book that came back. With one status atom a loan is :active, :returned or :lost, never two of them,
and each fine is one clause. The fines are whole paise, 200 a day late, so no float rounding creeps in, as the
numbers lesson advised for money.
Answer the two questions about a piece of your own data to see which shape fits:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Shape | Fixed known fields | Keys from the data | One of a few states | A result that can fail |
|---|---|---|---|---|
| A struct Looks like: %Loan{book_id: 42, member_id: 7} | Good a misspelt key stopped the compile of book_typo.exs | Poor its keys are fixed when the module compiles | Mixed holds a state as one field, such as status | Poor a struct says nothing about success or failure |
| A plain map Looks like: %{"Fiction" => 3, "Poetry" => 1} | Poor book[:title] gave nil for a misspelt key, with no error | Good any key, such as a count for each genre | Poor keys for states invite the returned-and-lost loan | Poor callers would have to look inside to know |
| An atom Looks like: :active, :returned or :lost | Poor an atom holds no fields | Poor an atom holds no keys | Good one status atom: never returned and lost at once | Mixed :ok alone carries no value back |
| A tagged tuple Looks like: {:ok, loan} or {:error, :invalid_loan} | Mixed fields by position, not by name | Poor a fixed number of places | Mixed works, but an atom alone says the same | Good Loan.new/3 answered {:ok, loan} or {:error, :invalid_loan} |
How the answers decide
- “Do you know every key of this data when you write the code?” No, the keys arrive with the data: rules out A struct. A struct fixes its keys when its module compiles; keys that come with the data, such as genres, need a map.
- “Do you know every key of this data when you write the code?” Yes, the same keys every time and “What is this piece of data?” A thing with fields: rules out An atom and A tagged tuple and adds a caveat to A plain map. For known fields a struct lets the compiler check every key; a map takes misspelt keys without a word.
- “What is this piece of data?” One of a few states: rules out A struct, A plain map and A tagged tuple. A state is one value: two booleans let fines.exs hold a loan both returned and lost, one atom cannot.
- “What is this piece of data?” A result that can fail: rules out A struct, A plain map and An atom. A tagged tuple lets the caller match success and failure, as the answers of Loan.new/3 do.
In short: a struct for a thing whose fields you know, such as a book or a loan; a plain map for keys that arrive
with the data, such as a count for each genre; an atom for one of a few states; and a tagged tuple, {:ok, loan} or
{:error, :invalid_loan}, for a result that can fail.
Checkpoint
Check: Choosing a shape for data
Practise: lending books
Three functions run the desk of a small library with the structs of this lesson: Library.checkout/3 lends a book to
a member, Library.return/2 takes it back, and Library.overdue?/2 says whether an active loan is late. Copy the two
files into an empty folder:
# A small library: books, members and loans. Write the three Library functions.
defmodule Book do
@enforce_keys [:id, :title]
defstruct [:id, :title, copies: 1]
end
defmodule Member do
@enforce_keys [:id, :name]
defstruct [:id, :name]
end
defmodule Loan do
@enforce_keys [:book_id, :member_id, :due]
defstruct [:book_id, :member_id, :due, :returned_on, status: :active]
end
defmodule Library do
# Lends one copy of book to member on today: {:ok, loan, book}, with the loan
# due 14 days later and the book's copies one fewer; or
# {:error, :no_copies} when no copy is on the shelf.
def checkout(%Book{} = _book, %Member{} = _member, %Date{} = _today), do: {:error, :no_copies}
# Marks an active loan returned on today: {:ok, loan}; a loan that is not
# active gives {:error, :not_active}.
def return(%Loan{} = _loan, %Date{} = _today), do: {:error, :not_active}
# Whether an active loan is past its due date on today.
def overdue?(%Loan{} = _loan, %Date{} = _today), do: false
end Code.require_file("library.ex", __DIR__)
ExUnit.start(seed: 0)
defmodule LibraryTest do
use ExUnit.Case
@today ~D[2026-11-02]
@gora %Book{id: 42, title: "Gora", copies: 2}
@asha %Member{id: 7, name: "Asha"}
test "checkout lends a copy for 14 days" do
assert {:ok, loan, book} = Library.checkout(@gora, @asha, @today)
assert loan == %Loan{book_id: 42, member_id: 7, due: ~D[2026-11-16]}
assert book.copies == 1
end
test "checkout with no copy on the shelf" do
assert Library.checkout(%{@gora | copies: 0}, @asha, @today) == {:error, :no_copies}
end
test "return marks the loan returned on the day" do
loan = %Loan{book_id: 42, member_id: 7, due: ~D[2026-11-16]}
assert {:ok, returned} = Library.return(loan, ~D[2026-11-10])
assert returned.status == :returned
assert returned.returned_on == ~D[2026-11-10]
end
test "a loan is returned only once" do
loan = %Loan{book_id: 42, member_id: 7, due: ~D[2026-11-16], status: :returned}
assert Library.return(loan, @today) == {:error, :not_active}
end
test "overdue only after the due date, and only while active" do
loan = %Loan{book_id: 42, member_id: 7, due: ~D[2026-11-16]}
refute Library.overdue?(loan, ~D[2026-11-16])
assert Library.overdue?(loan, ~D[2026-11-17])
refute Library.overdue?(%{loan | status: :returned}, ~D[2026-12-01])
end
test "dates compare as dates across the end of a year" do
loan = %Loan{book_id: 42, member_id: 7, due: ~D[2026-12-31]}
assert Library.overdue?(loan, ~D[2027-01-01])
end
end Run elixir library_test.exs. The starter’s placeholder answers pass two of the six tests:
from session import run
# The tests against the starter, whose functions return placeholder answers.
run("""
cd practice
!elixir library_test.exs
""") Output
$ cd practice
$ elixir library_test.exs
Running ExUnit with seed: 0, max_cases: 36
1) test checkout lends a copy for 14 days (LibraryTest)
library_test.exs:11
match (=) failed
code: assert {:ok, loan, book} = Library.checkout(@gora, @asha, @today)
left: {:ok, loan, book}
right: {:error, :no_copies}
stacktrace:
library_test.exs:12: (test)
.
2) test return marks the loan returned on the day (LibraryTest)
library_test.exs:21
match (=) failed
code: assert {:ok, returned} = Library.return(loan, ~D[2026-11-10])
left: {:ok, returned}
right: {:error, :not_active}
stacktrace:
library_test.exs:23: (test)
.
3) test overdue only after the due date, and only while active (LibraryTest)
library_test.exs:33
Expected truthy, got false
code: assert Library.overdue?(loan, ~D[2026-11-17])
arguments:
# 1
%Loan{
book_id: 42,
member_id: 7,
due: ~D[2026-11-16],
returned_on: nil,
status: :active
}
# 2
~D[2026-11-17]
stacktrace:
library_test.exs:36: (test)
4) test dates compare as dates across the end of a year (LibraryTest)
library_test.exs:40
Expected truthy, got false
code: assert Library.overdue?(loan, ~D[2027-01-01])
arguments:
# 1
%Loan{
book_id: 42,
member_id: 7,
due: ~D[2026-12-31],
returned_on: nil,
status: :active
}
# 2
~D[2027-01-01]
stacktrace:
library_test.exs:42: (test)
Finished in 0.7 seconds (0.6s on load, 0.00s async, 0.1s sync)
Result: 2/6 passed
Failed: 4 tests
[exit status 2]
This output changes from run to run: ExUnit prints how long the tests took, and max_cases follows the computer's processors
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py
Lend with two clauses: one for a book whose copies is 0, and one that builds the loan with Date.shift/2, which the
Date documentation prefers to Date.add/2, and hands back the book with one copy fewer through an update. Take a
loan back only when its status is :active. Compare dates with Date.after?/2, never with >: the last test crosses
the end of a year, where a structural comparison of two dates gives the wrong answer. A finished version passes all
six:
from session import run
# The same tests against a finished Library module.
run("""
cd finished
cp ../practice/library_test.exs .
elixir library_test.exs
""") Output
$ cd finished $ cp ../practice/library_test.exs . $ elixir library_test.exs Running ExUnit with seed: 0, max_cases: 36 ...... Finished in 0.7 seconds (0.7s on load, 0.00s async, 0.04s sync) Result: 6 passed
This output changes from run to run: ExUnit prints how long the tests took, and max_cases follows the computer's processors
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 finished_session.py
Finish with the self-check on the seven ideas, from fixed keys to the shape of data. It picks each next question from the idea you have missed most, and tells you why the choice you made is right or not:
Practice that adapts to you
Practise this lesson
18 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what is the difference between a struct and a plain map in Elixir?
A struct is a map with an extra __struct__ key that names its module, and a fixed set of keys that defstruct
declares. So the compiler rejects a struct literal with an unknown key, @enforce_keys makes chosen keys required
when the struct is built, and the 1.20 type checker warns about a field that a matched struct does not have. A plain
map takes any key at any time. Structs share the map syntax for dot access, updates and pattern matching, and
is_map/1 is true for them, but they implement none of the protocols maps implement: loan[:status] raises because
structs do not implement Access, and Enum refuses a struct unless its module implements Enumerable. Use a struct for
data whose fields you know, such as a loan, and a map for keys that arrive with the data.
Two questions from code that models and moves data:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
After an upgrade from Elixir 1.19 to 1.20, a refund that reached mark_paid/1 by mistake came back as a refund marked paid instead of crashing. The function is def mark_paid(order), do: %Order{order | status: :paid}. Explain what changed, and how you find every function like it before the upgrade ships.
-
Fresher to mid
A sign-up endpoint builds members with struct(Member, JSON.decode!(body)). Every member created since launch has a nil name and email, though the requests carry both, and nothing failed or was logged. Explain the cause, fix it, and make a missing or unexpected field fail loudly from now on.
Key takeaways
- A struct is a map with a
__struct__key and a fixed set of keys, and a struct literal with an unknown key stops the compile of the module that contains it, where a plain map takes the misspelt key without a word. @enforce_keysmakes keys required in struct literals and instruct!/2, but it checks no value and no update, andstruct/2skips it, so the values need a building function such asLoan.new/3.- Read fields with a dot and update them with
%{loan | key: value}; brackets and Enum fail on a struct, because it implements neither Access nor Enumerable. - Matching
%Loan{} = loanwhere a value enters lets Elixir 1.20 warn about every misspelt field, and on 1.20.4 the%Loan{loan | …}update was checked only by those warnings, not when the code ran. - A struct pattern checks the struct’s type and a map pattern only its keys, so a loan matches
%{status: s}while a plain map never matches%Loan{}. - Bring JSON in through an allowlist and
struct!/2, send structs out through@derive {JSON.Encoder, only: …}, and give states one atom each and results a tagged tuple.
Terms in this lesson
- allowlist
- A list of what is allowed, such as the JSON fields a client may set: anything not on it is dropped or refused, instead of trying to list everything that is forbidden.
- enforced key
- A key listed in @enforce_keys before defstruct: a struct literal or struct!/2 without it raises ArgumentError. Updates are not checked, and nil counts as a value.
- struct
- A map with an extra __struct__ key that names the module defining it, and a fixed set of keys declared with defstruct. A struct literal with an unknown key stops the compile of the module that contains it.
References
- Elixir guide: Structs (The Elixir Team (hexdocs.pm))
- Kernel (Elixir): defstruct/1, struct/2 and struct!/2 (The Elixir Team (hexdocs.pm))
- Kernel.SpecialForms (Elixir): %struct{} (The Elixir Team (hexdocs.pm))
- Gradual set-theoretic types: struct updates (The Elixir Team (hexdocs.pm))
- JSON (Elixir) (The Elixir Team (hexdocs.pm))
- JSON.Encoder (Elixir) (The Elixir Team (hexdocs.pm))
- Design-related anti-patterns (Elixir) (The Elixir Team (hexdocs.pm))
- Date (Elixir): comparing dates, after?/2 and shift/2 (The Elixir Team (hexdocs.pm))
- Mass Assignment Cheat Sheet (OWASP Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress