Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with elixir 1.20.4, erlang 29.1.1 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

book.exs Elixir · book.exs
# 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()
Three books, and what a struct is underneath Python · book_session.py
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:

book_typo.exs Elixir · book_typo.exs
# 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")
shelf_label.exs Elixir · shelf_label.exs
# 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()
The same slip in a struct and in a plain map Python · typo_session.py
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:

Interactive · message by message How a misspelt key in %Book{…} stops the compile Step through the compile of book_typo.exs. The middle column is the compiler; the right one is the Book module.
How a misspelt key in %Book{…} stops the compile, step 5 of 5. Messages so far: Main.run/0 to The compiler: %Book{titel: …}; The compiler to Book: Book.__struct__/1; Book to The compiler: KeyError: :titel.Main.run/0%Book{titel: …}The compilerstopped: KeyErrorBooktitle, author, copies%Book{titel: …}Book.__struct__/1KeyError: :titelnever compiled

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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".
  5. 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

  1. One question book_typo.exs printed "the script starts" and stopped with KeyError. Why was "the script ends" never printed?

    Choose one answer.

    Show the answer: One question

    Answer: The compile of Main failed on the struct literal, so run/0 never existed and nothing after it ran

    The script ran its first line, compiled Book, and then failed to compile Main: the literal %Book{titel: …} was checked against Book's keys and titel is not one of them. With Main never compiled, the last two lines never ran.

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:

loan.exs Elixir · loan.exs
# 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()
loan_missing.exs Elixir · loan_missing.exs
# 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()
What @enforce_keys lets through, and the literal it stops Python · enforce_session.py
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:

loan_new.exs Elixir · loan_new.exs
# @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()
A loan built only from values that make sense Python · new_session.py
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

  1. Question 1 of 2 Loan has @enforce_keys [:book_id, :member_id]. Which of these builds a loan without any error?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: %Loan{book_id: nil, member_id: nil}

    Enforced keys must be present when a struct literal or struct!/2 builds the struct, and nothing checks what they hold, which is why loan.exs built a loan with nil for both.

  2. Question 2 of 2 An import builds each loan with struct(Loan, row), where row is a keyword list. Some rows have no member_id. What happens to them?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Each becomes a loan with member_id set to nil, and nothing raises

    Use struct!/2, which enforces the keys and checks every key, or better a function such as Loan.new/3 that checks the values, when the data comes from outside the code.

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:

read_update.exs Elixir · read_update.exs
# 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()
Reading and updating a loan, and two things a struct refuses Python · read_session.py
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

  1. Question 1 of 2 What does loan[:status] do when loan is a %Loan{}?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It raises UndefinedFunctionError, because Loan does not implement the Access behaviour

    A struct shares the map syntax for dot access and updates, not the Access behaviour, so loan.status and Map.get(loan, :status) work and loan[:status] raises.

  2. Question 2 of 2 What does the last line return?

    Read the code, then choose one answer.

    loan = %Loan{book_id: 42, member_id: 7}
    returned = %{loan | status: :returned}
    loan.status
    Show the answer: Question 2 of 2

    Answer: :active

    %{loan | status: :returned} returns a new struct with one field changed. The variable loan still refers to the old one, whose status is :active.

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:

checks/loans.ex Elixir · checks/loans.ex
# 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
Two slips caught while compiling, one when the code runs Python · checks_session.py
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:

update/return.ex Elixir · update/return.ex
# 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
%Loan{loan | …} on Elixir 1.20.4 and 1.19.6, then fixed Python · update_session.py
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

  1. Question 1 of 2 Which of these slips are reported while the code compiles, before it runs?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • loan.due_on in a function whose head matched %Loan{} = loan
    • %Book{titel: "Gora"}

    The compiler checks what it knows: struct literals always, and fields of values that a pattern says are a %Loan{}. A value of unknown type is checked only when the code runs, and a map read with brackets not even then.

  2. Question 2 of 2 On Elixir 1.20.4, def return(loan), do: %Loan{loan | status: :returned} is called by mistake with a %Book{} that has a status key. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It returns the book with its status set to :returned; only the compile warning flagged the mistake

    On 1.20.4 the struct update syntax is checked by the type checker, which can only prove the type when the struct is matched where the variable is bound. Match %Loan{} = loan in the head and the wrong call fails at once.

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:

desk.exs Elixir · desk.exs
# 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:

Interactive · stage by stage Four values through the clauses of Desk.describe/1 Step through the four values. Each stage is a clause, tried from the top; the sub-label says which value it took.
Four values through the clauses of Desk.describe/1, step 5 of 5. Showing %Loan{status: :returned} (took the returned loan), %Loan{member_id: id} (took the active loan), %name{} (took the book), %{status: status} (took the plain map).%Loan{status: :returned}took the returned loan%Loan{member_id: id}took the active loan%name{}took the book%{status: status}took the plain map

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

Four values through describe/1, then the clauses out of order Python · desk_session.py
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

  1. Question 1 of 2 Which clause of Desk.describe/1 takes %Loan{book_id: 1, member_id: 3, status: :lost}?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: describe(%Loan{member_id: id}), with 3 bound to id

    Clauses are tried from the top, and the first whose pattern fits runs. A lost loan is a Loan whose status is not :returned, so the second clause takes it.

  2. Question 2 of 2 Does the pattern %{status: :active} match %Loan{book_id: 42, member_id: 9}?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Yes, because a struct is a map and this loan's status is :active

    A map pattern matches any map with the named keys and values, structs included. Only a struct pattern such as %Loan{} also checks __struct__, which is why the plain map never matched the Loan clauses.

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:

member_in.exs Elixir · member_in.exs
# 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()
One sign-up body through struct/2, struct!/2 and an allowlist Python · in_session.py
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]:

member_out.exs Elixir · member_out.exs
# 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()
A member as JSON, and the shortcut that leaks every field Python · out_session.py
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:

Interactive · stage by stage A member coming in from JSON and going out as JSON Step through one sign-up body. Each stage is one step of the code; the side arrows are the two shortcuts that go wrong.
A member coming in from JSON and going out as JSON, step 8 of 8. Showing The JSON body, JSON.decode!/1, The allowlist (role dropped), struct!/2, %Member{}, JSON.encode!/1 ({"id":7,"name":"Asha"}). In focus: JSON.encode!/1.struct/2Map.from_structThe JSON bodyid, name, email, roleJSON.decode!/1string keysThe allowlistrole droppedstruct!/2checks every key%Member{}role: :readerJSON.encode!/1{"id":7,"name":"Asha"}Map.from_structevery field leaked

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. The body arrives. A sign-up request carries id, name, email and a role the client chose itself, "admin".
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. The member. The member has the fields the client may set, and role keeps its default, :reader.
  7. 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.
  8. 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.

JSON Diff & Compare Compare an endpoint's JSON before and after a change to a struct, to see every field a response gained.

Checkpoint

Check: JSON in and out

  1. Question 1 of 2 What does struct(Member, %{"id" => 7, "name" => "Asha"}) return, for defstruct [:id, :name, :email]?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A member with id, name and email all nil

    JSON gives string keys, and struct/2 silently drops every key that is not a field. Map the allowed JSON names to atom fields first, then build with struct!/2, as Member.from_json/1 does.

  2. Question 2 of 2 Member has @derive {JSON.Encoder, only: [:id, :name]}. Which call sends the pin_hash out?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: member |> Map.from_struct() |> JSON.encode!()

    only: belongs to the struct's encoder, and a plain map made from the struct has none, so every field leaves. Encode the struct itself, and test the keys of the JSON that goes 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:

fines.exs Elixir · fines.exs
# 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()
Fines from two models of a loan Python · model_session.py
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:

Interactive · answer and compare Which shape fits this piece of data? Answer the two questions about a piece of your own data. The table marks the shapes that fit, and why.
Do you know every key of this data when you write the code?
What is this piece of data?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

A struct, a plain map, an atom and a tagged tuple against four jobs the library's data does
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

  1. Question 1 of 2 FlagLoan records a loan with two booleans, returned? and lost?. How many combinations of the two can a loan hold?

    Type a number.

    A hint

    Each boolean is true or false, independently of the other.

    Show the answer: Question 1 of 2

    Answer: 4 combinations

    2 × 2 = 4: neither, returned, lost, and both. The last means nothing, yet fines.exs charged it the lost-book fine. One status atom, :active, :returned or :lost, holds exactly three states.

  2. Question 2 of 2 Librarians add new genres at any time. Which shape fits the number of books in each genre?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A plain map, such as %{"Fiction" => 3, "Poetry" => 1}

    A struct suits a thing whose fields you know when you write the code; keys that come with the data, such as genres, belong in a map.

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:

practice/library.ex: the starter Elixir · practice/library.ex
# 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
practice/library_test.exs: the tests Elixir · practice/library_test.exs
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:

The tests against the starter Python · practice_session.py
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:

The tests against a finished Library module Python · finished_session.py
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.

  1. Question 1 of 18 With defstruct [:title, :author, copies: 1], what is %Book{}?

    Choose one answer.

    Show the answer: Question 1 of 18

    Answer: %Book{title: nil, author: nil, copies: 1}

    defstruct gives every listed field a key: nil when no default is given, the default otherwise, so %Book{} is a full book with two nil fields.

  2. Question 2 of 18 For defstruct [:title, :author, copies: 1], what is map_size(%Book{title: "Gora"})?

    Type a number.

    A hint

    A struct is a map with one extra key.

    Show the answer: Question 2 of 18

    Answer: 4 keys

    Three fields plus __struct__, which holds the module's name, make four keys, as book.exs printed for gora.

  3. Question 3 of 18 A function that is never called contains %Book{titel: "Gora"}. What happens?

    Choose one answer.

    Show the answer: Question 3 of 18

    Answer: The module that contains it fails to compile

    Struct literals are expanded at compile time, so an unknown key stops the compile of the whole module, which is how the struct turned the label printer's silent slip into an error before anything ran.

  4. Question 4 of 18 What does @enforce_keys check?

    Choose one answer.

    Show the answer: Question 4 of 18

    Answer: That each listed key is given when a struct literal or struct!/2 builds the struct

    Enforced keys are a guard against forgetting a field in code. They are not enforced on updates and do not validate values.

  5. Question 5 of 18 With @enforce_keys [:book_id, :member_id], what does this return?

    Read the code, then choose one answer.

    loan = %Loan{book_id: 42, member_id: 7}
    %{loan | member_id: nil}
    Show the answer: Question 5 of 18

    Answer: A loan with member_id: nil

    The update changes a key the struct has, so it succeeds, nil and all. Keep values valid by building and changing loans through functions that check them.

  6. Question 6 of 18 Which of these read the due field of a %Loan{} without raising?

    Choose every answer that is right.

    Show the answer: Question 6 of 18

    Answer:

    • loan.due
    • Map.get(loan, :due)

    Read fields with the dot or the Map functions. Brackets and Enum need protocols that structs do not implement unless their module does it.

  7. Question 7 of 18 Loan has no renewals field. What does %{loan | renewals: 1} do?

    Choose one answer.

    Show the answer: Question 7 of 18

    Answer: It raises KeyError, because the update syntax only changes keys that exist

    %{loan | key: value} changes keys the struct has and raises KeyError for others, which keeps a struct's keys fixed after it is built too.

  8. Question 8 of 18 What does matching %Loan{} = loan in a function head give you?

    Choose one answer.

    Show the answer: Question 8 of 18

    Answer: The compiler knows loan is a Loan, warns about misspelt fields, and a wrong value fails at the call

    A struct pattern where a value enters is both a check at the door when the code runs and the information the type checker needs to warn about every field the function touches.

  9. Question 9 of 18 def due(loan), do: loan.due_on, with due as the real field. When is the slip reported?

    Choose one answer.

    Show the answer: Question 9 of 18

    Answer: When due/1 runs, as KeyError with "Did you mean: :due"

    The type checker can only warn about what it knows. A function that takes any value learns about the slip when it runs, which is the case for matching the struct in the head.

  10. Question 10 of 18 A codebase moves from Elixir 1.19.6 to 1.20.4. Its tests used to catch a wrong struct passed to %Order{order | …} with BadStructError. What keeps that protection?

    Choose one answer.

    Show the answer: Question 10 of 18

    Answer: Matching %Order{} = order where the variable is bound, and treating the compiler's warnings as errors

    On 1.20.4 the struct update syntax relies on the type checker. A struct match where the value is bound proves the type to the checker and checks it when the code runs; warnings as errors make sure none is left unproven.

  11. Question 11 of 18 What does %name{} = %Book{title: "Gora"} bind to name?

    Choose one answer.

    Show the answer: Question 11 of 18

    Answer: Book, the struct's module

    %name{} checks that the value is a struct and binds its module; %_{} checks the same and binds nothing.

  12. Question 12 of 18 describe/1 has %name{} as its first clause and two %Loan{} clauses after it. What does Elixir 1.20 report?

    Choose one answer.

    Show the answer: Question 12 of 18

    Answer: That both %Loan{} clauses are redundant, because %name{} already takes every struct

    The first clause whose pattern fits runs, so a wide pattern placed early hides every narrower one below it. Order clauses from the narrowest to the widest.

  13. Question 13 of 18 What keys does JSON.decode!(~s({"id": 7})) give the map?

    Choose one answer.

    Show the answer: Question 13 of 18

    Answer: The string "id"

    JSON gives string keys, so data from outside has to be mapped onto a struct's atom keys, ideally through a fixed list of allowed names.

  14. Question 14 of 18 A sign-up body also sends "role": "admin". Why does Member.from_json/1 leave the role at :reader?

    Choose one answer.

    Show the answer: Question 14 of 18

    Answer: "role" is not in its allowlist, so the field is never passed to struct!/2

    Choosing which fields a request may set is the job of the allowlist. Without it, binding request fields onto the struct would let a client pick its own role, the mass assignment the OWASP cheat sheet warns about.

  15. Question 15 of 18 Member derives JSON.Encoder with only: [:id, :name]. Later a phone field is added. What does JSON.encode!(member) send now?

    Choose one answer.

    Show the answer: Question 15 of 18

    Answer: Still only id and name

    This is why the JSON.Encoder documentation says to prefer :only: a field added later never leaks by default.

  16. Question 16 of 18 What is boolean obsession, in the words of the design anti-patterns guide?

    Choose one answer.

    Show the answer: Question 16 of 18

    Answer: Using booleans instead of atoms to encode information

    When two or more booleans stand for one state, some combinations mean nothing. One atom per state, such as status: :lost, makes those combinations impossible.

  17. Question 17 of 18 Loan.new/3 can fail. Which return value lets its caller handle both outcomes with a case?

    Choose one answer.

    Show the answer: Question 17 of 18

    Answer: {:ok, loan} or {:error, :invalid_loan}

    Results that can fail are tagged tuples in Elixir: a case matches {:ok, loan} and {:error, reason} apart, and the error carries the reason.

  18. Question 18 of 18 A loan can now be renewed at most twice. How do you add that to Loan?

    Choose one answer.

    Show the answer: Question 18 of 18

    Answer: An integer field such as renewals: 0, next to the status atom

    Give each kind of information the shape that fits it: the state as one atom, a count as an integer field, both in the struct whose fields you know.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

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_keys makes keys required in struct literals and in struct!/2, but it checks no value and no update, and struct/2 skips it, so the values need a building function such as Loan.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{} = loan where 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.