Elixir & Erlang/OTP Module 5 – Recursion, collections and data modelling
Protocols: polymorphism over data
Write Elixir protocols with defprotocol and defimpl, see how calls dispatch on type, control printing with String.Chars and Inspect, and fill your own structs.
What you will learn
- Define a protocol and implement it for structs and built-in types with defimpl
- Predict which implementation a call runs, and what happens when there is none, with @derive or with @fallback_to_any
- Implement String.Chars and derive Inspect to control how a struct prints and what it hides
- Implement Collectable and Enumerable so that Enum.into/2, for and Enum work on a struct of your own
- Explain what protocol consolidation changes in a Mix project, in tests and in speed
Before you start
What you’ll learn · Before you start
What you will learn
- Define a protocol and implement it for structs and built-in types with defimpl
- Predict which implementation a call runs, and what happens when there is none, with @derive or with @fallback_to_any
- Implement String.Chars and derive Inspect to control how a struct prints and what it hides
- Implement Collectable and Enumerable so that Enum.into/2, for and Enum work on a struct of your own
- Explain what protocol consolidation changes in a Mix project, in tests and in speed
Before you start
On this page
A courier charges by weight, and its items come in four shapes: parcels, envelopes, bare numbers of grams, and lists
that mix all three. A case with a clause for each shape works until another team brings a fifth and has to edit your
function; a protocol: A set of functions that dispatch on the type of their first argument: each type, such as a struct, List or Integer, brings its own implementation with defimpl. A call for a type without one raises Protocol.UndefinedError, unless the protocol falls back to Any. lets each type bring its own Weight.grams/1, and Elixir runs the one
that matches the value.
Below, you will write the protocol and its implementations, see which implementation a struct gets and what happens
when there is none, decide how a struct prints for people and for logs, make a struct of your own work with
Enum.into/2 and every Enum function, and measure what Mix gains by consolidating protocols. The runs on this page
used Elixir 1.20.4 on Erlang/OTP 29.1.1.
A protocol picks the implementation by the type of its first argument
defprotocol names a protocol and the functions it promises, here one, grams/1.
defimpl Weight, for: Parcel then writes that function for one type: a struct, or a built-in type such as Integer
or List. The protocols guide says “dispatching will always be based on the data type of the first input”. The
courier’s weight.exs:
# A courier charges by weight, and its items come in several shapes.
defprotocol Weight do
@doc "The weight of an item in grams."
def grams(item)
end
defmodule Parcel do
defstruct [:awb, grams: 0]
end
defmodule Envelope do
defstruct sheets: 1
end
defimpl Weight, for: Parcel do
def grams(parcel), do: parcel.grams
end
defimpl Weight, for: Envelope do
# 10 g for the envelope and 5 g for each sheet in it.
def grams(envelope), do: 10 + 5 * envelope.sheets
end
defimpl Weight, for: Integer do
# A bare number is already grams.
def grams(n), do: n
end
defimpl Weight, for: List do
# A list weighs what its items weigh, whatever each item is.
def grams(items), do: items |> Enum.map(&Weight.grams/1) |> Enum.sum()
end
defmodule Main do
def run do
parcel = %Parcel{awb: "AWB1042", grams: 1200}
envelope = %Envelope{sheets: 12}
IO.inspect(Weight.grams(parcel), label: "parcel")
IO.inspect(Weight.grams(envelope), label: "envelope")
IO.inspect(Weight.grams(250), label: "250")
IO.inspect(Weight.grams([parcel, envelope, 250]), label: "all three")
IO.inspect(Weight.impl_for(envelope), label: "impl_for(envelope)")
IO.inspect(Weight.impl_for("2 kg"), label: "impl_for(\"2 kg\")")
Weight.grams("2 kg")
end
end
Main.run() from session import run
run("""
!elixir weight.exs
""") Output
$ elixir weight.exs
parcel: 1200
envelope: 70
250: 250
all three: 1520
impl_for(envelope): Weight.Envelope
impl_for("2 kg"): nil
** (Protocol.UndefinedError) protocol Weight not implemented for BitString
Got value:
"2 kg"
weight.exs:2: Weight.impl_for!/1
weight.exs:4: Weight.grams/1
weight.exs:48: (file)
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 weight_session.py
The parcel weighed its grams field, 1,200, the envelope 10 g plus 5 g for each of its 12 sheets, 70, and the bare
number itself. The list weighed 1,520, because Weight.List calls Weight.grams/1 again on each item, and each item
is dispatched on its own type. impl_for/1, which every protocol has, named the implementation a value gets:
Weight.Envelope for the envelope, and nil for the string "2 kg", which made the last call raise
Protocol.UndefinedError, “protocol Weight not implemented for BitString”. Step through the list call:
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- One call for the whole list. Main.run/0 calls Weight.grams/1 with a list of a parcel, an envelope and the number 250.
- A list goes to Weight.List. The protocol looks at the type of its first argument. It is a list, so it runs the implementation for List, Weight.List, which impl_for/1 names.
- The parcel goes back. Weight.List calls Weight.grams/1 again, once for each item, so every item is dispatched on its own type. First the parcel.
- A parcel weighs its grams. The parcel is a %Parcel{} struct, so the protocol runs Weight.Parcel, which answers its grams field: 1,200.
- The envelope goes back. Next the envelope, again through the protocol.
- An envelope counts sheets. Weight.Envelope adds 10 g for the envelope and 5 g for each of its 12 sheets: 70.
- A bare number. The last item, the number 250, goes back through the protocol too.
- An integer is grams already. It is an integer, so the protocol runs Weight.Integer, which answers the number itself: 250.
- The sum comes back. Weight.List adds the three answers, 1,200 + 70 + 250, and the call returns 1,520, as weight.exs printed. A string such as "2 kg" has no implementation, and the protocol raises.
A function with a clause per type would give the same numbers. The guide explains the difference: such code “could be
problematic if it was shared as a dependency by multiple apps”, because only its owner can add a type, while “a
protocol can be implemented by anyone, at any time”. A team with a Pallet struct writes defimpl Weight, for: Pallet
in its own code, and Weight.grams/1 weighs pallets without a change to the courier’s module.
Key idea
A protocol is a function that each type implements in its own defimpl, and every call runs the implementation for
the type of its first argument, even when the call comes from inside another implementation.
Checkpoint
Check: Dispatch on the first argument's type
A struct gets only its own implementation, unless it derives one
A struct is a map, but for protocols it is a type of its own: the guide says structs “do not share protocol
implementations with maps”. derive.exs implements Weight for Map, for decoded JSON such as
%{"grams" => 300}, and for Any, then defines two structs: Sack, which derives Weight, and Box, which does
not:
# Structs do not use the Map implementation; deriving gives them the Any one.
defprotocol Weight do
def grams(item)
end
defimpl Weight, for: Map do
# A decoded JSON item such as %{"grams" => 300}.
def grams(%{"grams" => grams}), do: grams
end
defimpl Weight, for: Any do
# For structs that derive Weight: their grams field.
def grams(%{grams: grams}), do: grams
end
defmodule Box do
defstruct grams: 0
end
defmodule Sack do
@derive Weight
defstruct grams: 0
end
defmodule Main do
def run do
IO.inspect(Weight.grams(%{"grams" => 300}), label: "a decoded map")
IO.inspect(Weight.impl_for(%Sack{}), label: "impl_for(%Sack{})")
IO.inspect(Weight.grams(%Sack{grams: 900}), label: "a sack")
IO.inspect(Weight.impl_for(%Box{}), label: "impl_for(%Box{})")
Weight.grams(%Box{grams: 400})
end
end
Main.run() from session import run
# A Map implementation, a struct that derives Weight, and a struct that has no implementation.
run("""
!elixir derive.exs
""") Output
$ elixir derive.exs
a decoded map: 300
impl_for(%Sack{}): Weight.Sack
a sack: 900
impl_for(%Box{}): nil
** (Protocol.UndefinedError) protocol Weight not implemented for Box (a struct)
Got value:
%Box{grams: 400}
derive.exs:2: Weight.impl_for!/1
derive.exs:3: Weight.grams/1
derive.exs:35: (file)
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 derive_session.py
@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. before defstruct gave Sack an implementation, Weight.Sack, that
points at the one for Any, which reads the grams field, so the sack weighed 900. Box has a grams field too, and Weight.Map exists, yet
impl_for/1 found nothing for a box and the call raised. The other way to reach Any is @fallback_to_any true in
the protocol, which sends every type without an implementation there. fallback.exs uses it with the friendliest
default, zero:
# With @fallback_to_any, a type nobody implemented gets the Any implementation.
defprotocol Weight do
@fallback_to_any true
def grams(item)
end
defimpl Weight, for: Any do
# "Unknown things weigh nothing": a default that hides every missing implementation.
def grams(_item), do: 0
end
defimpl Weight, for: Integer do
def grams(n), do: n
end
defmodule Fee do
# 40 rupees up to 500 g, then 20 rupees for each 500 g or part of it, in paise.
def paise(item) do
extra = max(Weight.grams(item) - 500, 0)
4_000 + div(extra + 499, 500) * 2_000
end
end
defmodule Main do
def run do
IO.inspect(Weight.grams(2_500), label: "2,500 g")
IO.inspect(Weight.grams([1_200, 1_300]), label: "a list of 1,200 g and 1,300 g")
IO.inspect(Fee.paise(2_500), label: "fee for 2,500 g, paise")
IO.inspect(Fee.paise([1_200, 1_300]), label: "fee for the list, paise")
end
end
Main.run() from session import run
run("""
elixir fallback.exs
""") Output
$ elixir fallback.exs 2,500 g: 2500 a list of 1,200 g and 1,300 g: 0 fee for 2,500 g, paise: 12000 fee for the list, paise: 4000
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fallback_session.py
2,500 g came to a fee of 12,000 paise. The list of 1,200 g and 1,300 g, also 2,500 g, has no implementation of its
own in this protocol, so it fell back to Any, weighed 0, and paid the 4,000 paise minimum: an undercharge of 8,000
paise, with no error anywhere. The guide’s advice is to keep the error: “For the majority of protocols, raising an
error when a protocol is not implemented is the proper behavior.” What each value ran:
| Value | Implementation it ran |
|---|---|
%{"grams" => 300} |
Weight.Map |
%Sack{}, which derives Weight |
Weight.Sack, which points at Weight.Any |
%Box{}, with no implementation |
none: Protocol.UndefinedError |
[1_200, 1_300], with @fallback_to_any |
Weight.Any, which answered 0 |
A default that hides a missing implementation
@fallback_to_any true with an Any implementation that returns 0, "" or [] turns every forgotten defimpl
into a silent wrong answer. Leave the fallback off and let the call raise, or derive the implementation for the
structs that should share it.
Checkpoint
Check: A struct's own implementation
String.Chars decides interpolation, Inspect decides what logs show
Two protocols decide how a struct turns into text. String.Chars is the one for people: the String.Chars documentation
says “String interpolation also invokes to_string/1 in its arguments”. Inspect is the one for programmers, used by
inspect/1, IO.inspect/2, IEx and error messages, and a struct can derive it with only: or except: to leave
fields out. printing.exs does both:
# Two protocols decide how a struct prints: String.Chars for people, Inspect for logs.
defmodule Parcel do
defstruct [:awb, grams: 0]
end
defimpl String.Chars, for: Parcel do
def to_string(parcel), do: "parcel #{parcel.awb} (#{parcel.grams} g)"
end
defmodule Courier do
# inspect/1, IO.inspect/2 and error messages show only the name.
@derive {Inspect, only: [:name]}
defstruct [:name, :api_token]
end
defmodule Main do
def run do
parcel = %Parcel{awb: "AWB1042", grams: 1200}
IO.puts("Booked #{parcel}")
IO.inspect(parcel, label: "inspect")
ravi = %Courier{name: "Ravi", api_token: "demo-token-9f2c41d8"}
IO.inspect(ravi, label: "inspect")
IO.puts("inspect/1 gives " <> inspect(ravi))
try do
IO.puts("Courier: #{ravi}")
rescue
error -> IO.puts("interpolation raised: " <> Exception.message(error))
end
# The token is still in the struct; deriving changes only how it prints.
IO.inspect(ravi, label: "structs: false", structs: false)
IO.inspect(Map.from_struct(ravi), label: "Map.from_struct")
end
end
Main.run() from session import run
run("""
elixir printing.exs
""") Output
$ elixir printing.exs
Booked parcel AWB1042 (1200 g)
inspect: %Parcel{awb: "AWB1042", grams: 1200}
inspect: #Courier<name: "Ravi", ...>
inspect/1 gives #Courier<name: "Ravi", ...>
interpolation raised: protocol String.Chars not implemented for Courier (a struct)
Got value:
#Courier<name: "Ravi", ...>
structs: false: %{name: "Ravi", __struct__: Courier, api_token: "demo-token-9f2c41d8"}
Map.from_struct: %{name: "Ravi", api_token: "demo-token-9f2c41d8"}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 printing_session.py
| What printed | What it showed |
|---|---|
"Booked #{parcel}" |
Booked parcel AWB1042 (1200 g), from String.Chars |
inspect(ravi) |
#Courier<name: "Ravi", ...>, no token |
"Courier: #{ravi}" |
Protocol.UndefinedError for String.Chars, the value shown without the token |
IO.inspect(ravi, structs: false) |
the plain map, token included |
Map.from_struct(ravi) |
the fields as a map, token included |
The Inspect documentation says deriving can “hide certain fields from structs, so they don’t show up in logs,
inspects and similar”, and the #Courier<…> form marks a struct printed with fields left out. The token stayed out of
inspect/1 and out of the error message, because the error message inspects its value too. It did not leave the
struct: structs: false printed the struct as the map it is, and Map.from_struct/1 handed every field over.
Derived Inspect is a printing rule, not a lock
Derive Inspect with only: for every struct that carries a secret, so the usual ways of printing leave it out;
then keep it out of places that do not print through Inspect, such as JSON built from Map.from_struct/1.
Checkpoint
Check: String.Chars and Inspect
Enumerable takes items out of a struct, Collectable puts them in
Enum works on any value whose type implements Enumerable, and Enum.into/2 and for … into: fill any value whose
type implements Collectable. The Collectable documentation calls Collectable.into/1 “the opposite of
Enumerable.reduce/3”. A Bag counts how many times each item went in, and implements both:
# A bag counts how many times each item was put in it.
defmodule Bag do
defstruct counts: %{}
def put(%Bag{counts: counts} = bag, item) do
%{bag | counts: Map.update(counts, item, 1, &(&1 + 1))}
end
end
# Collectable: how to put items in, for Enum.into/2 and for ... into:.
defimpl Collectable, for: Bag do
def into(bag) do
collector = fn
acc, {:cont, item} -> Bag.put(acc, item)
acc, :done -> acc
_acc, :halt -> :ok
end
{bag, collector}
end
end
# Enumerable: how to take items out, for every function of Enum.
defimpl Enumerable, for: Bag do
def count(bag), do: {:ok, bag.counts |> Map.values() |> Enum.sum()}
def member?(bag, item), do: {:ok, Map.has_key?(bag.counts, item)}
def slice(_bag), do: {:error, __MODULE__}
def reduce(bag, acc, fun) do
items = for {item, n} <- Enum.sort(bag.counts), _ <- 1..n, do: item
Enumerable.List.reduce(items, acc, fun)
end
end
defmodule Main do
def run do
orders = ["chai", "coffee", "chai", "samosa", "chai"]
bag = Enum.into(orders, %Bag{})
IO.inspect(bag, label: "Enum.into")
bag = for item <- ["coffee", "vada"], into: bag, do: item
IO.inspect(bag, label: "for, into: bag")
IO.inspect(Enum.count(bag), label: "Enum.count")
IO.inspect("vada" in bag, label: ~s("vada" in bag))
IO.inspect(Enum.to_list(bag), label: "Enum.to_list")
IO.inspect(Enum.take(bag, 2), label: "Enum.take(bag, 2)")
end
end
Main.run() from session import run
run("""
elixir bag.exs
""") Output
$ elixir bag.exs
Enum.into: %Bag{counts: %{"chai" => 3, "coffee" => 1, "samosa" => 1}}
for, into: bag: %Bag{counts: %{"chai" => 3, "coffee" => 2, "samosa" => 1, "vada" => 1}}
Enum.count: 7
"vada" in bag: true
Enum.to_list: ["chai", "chai", "chai", "coffee", "coffee", "samosa", "vada"]
Enum.take(bag, 2): ["chai", "chai"]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bag_session.py
Five orders went in through Enum.into/2, two more through for … into: bag, and the bag counted seven. Collectable
needs one function, into/1, which returns the starting value and a collector: Enum calls the collector with
{:cont, item} for each item, then with :done, when it must return the finished bag. Step through the first fill:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Enum.into/2 asks the bag. Enum.into/2 calls Collectable.into/1 with the empty bag. The implementation answers two things: the starting accumulator, the bag itself, and the collector function.
- The first item. For each order Enum.into/2 calls the collector with the accumulator and {:cont, item}. The first is "chai", and Bag.put/2 counts it.
- Two more. "coffee" and "chai" follow, one call each, and each call returns the new bag, which the next call receives.
- The last two. "samosa" and a third "chai" make five calls in all, one for each order.
- No more items. Then the collector gets :done, and must return the finished collectable: here, the bag as it is. Had the enumeration stopped early, it would get :halt instead.
- The filled bag. Enum.into/2 returns %Bag{counts: %{"chai" => 3, "coffee" => 1, "samosa" => 1}}, as bag.exs printed. A for comprehension with into: a bag goes through the same three steps.
Enumerable needs four. reduce/3 is the core, and here it builds a sorted list of the items and hands it to
Enumerable.List.reduce/3, the delegation the Enumerable documentation shows for a struct that holds a list, so
Enum.to_list/1 and Enum.take/2 work with no more code. count/1 and member?/2 answer at once from the counts,
which is why they return {:ok, …}. A callback that answers {:error, __MODULE__}, as slice/1 does here, tells Enum
to fall back to “a default algorithm built on top of reduce/3 that runs in linear time”.
Answer count/1 and member?/2 when you can
A struct that knows its size, or can look an item up, should say so in count/1 and member?/2: otherwise
Enum.count/1 and in walk every item through reduce/3.
Checkpoint
Check: Enumerable and Collectable
Mix consolidates protocols, which makes calls faster and checks them
protocol consolidation: The step at the end of compiling a Mix project that links each protocol to the implementations known then, so its calls dispatch faster. An implementation defined later, such as in a test file, has no effect. links a protocol to its implementations once they are
all known. The Protocol documentation says it “is applied by default to all Mix projects during compilation”; a
script gets none, as the first line of the next run shows. The project in examples/courier/ holds the same
protocol in lib/weight.ex, and its test file implements Weight for a struct of its own:
# A struct that only the tests use, with its own implementation of Weight.
defmodule Pallet do
defstruct boxes: []
end
defimpl Weight, for: Pallet do
def grams(pallet), do: Weight.grams(pallet.boxes)
end
defmodule WeightTest do
use ExUnit.Case
test "a pallet weighs what its boxes weigh" do
assert Weight.grams(%Pallet{boxes: [100, 250]}) == 350
end
end from session import run
# The same protocol loaded by a script, then compiled by Mix; then the tests of the project.
run("""
elixir -r courier/lib/weight.ex -e 'IO.inspect(Protocol.consolidated?(Weight)); IO.inspect(Weight.__protocol__(:impls))'
cd courier
mix run -e 'IO.inspect(Protocol.consolidated?(Weight)); IO.inspect(Weight.__protocol__(:impls))'
!mix test
""") Output
$ elixir -r courier/lib/weight.ex -e 'IO.inspect(Protocol.consolidated?(Weight)); IO.inspect(Weight.__protocol__(:impls))'
false
:not_consolidated
$ cd courier
$ mix run -e 'IO.inspect(Protocol.consolidated?(Weight)); IO.inspect(Weight.__protocol__(:impls))'
Compiling 1 file (.ex)
Generated courier app
true
{:consolidated, [Integer, List, Parcel]}
$ mix test
Compiling 1 file (.ex)
Generated courier app
Running ExUnit with seed: 0, max_cases: 36
warning: the Weight protocol has already been consolidated, an implementation for Pallet has no effect. If you want to implement protocols after compilation or during tests, check the "Consolidation" section in the Protocol module documentation
│
6 │ defimpl Weight, for: Pallet do
│ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
│
└─ test/weight_test.exs:6: (file)
warning: incompatible types given to Weight.grams/1:
Weight.grams(%Pallet{boxes: [100, 250]})
given types:
-%Pallet{boxes: non_empty_list(integer())}-
but expected a type that implements the Weight protocol.
You either passed the wrong value or you forgot to implement the protocol.
hint: the Weight protocol is implemented for the following types:
dynamic(%Parcel{}) or empty_list() or integer() or non_empty_list(term(), term())
type warning found at:
│
14 │ assert Weight.grams(%Pallet{boxes: [100, 250]}) == 350
│ ~
│
└─ test/weight_test.exs:14:19: WeightTest."test a pallet weighs what its boxes weigh"/1
1) test a pallet weighs what its boxes weigh (WeightTest)
test/weight_test.exs:13
** (Protocol.UndefinedError) protocol Weight not implemented for Pallet (a struct). This protocol is implemented for: Integer, List, Parcel
Got value:
%Pallet{boxes: [100, 250]}
code: assert Weight.grams(%Pallet{boxes: [100, 250]}) == 350
stacktrace:
(courier 0.1.0) lib/weight.ex:1: Weight.impl_for!/1
(courier 0.1.0) lib/weight.ex:3: Weight.grams/1
test/weight_test.exs:14: (test)
Finished in 0.4 seconds (0.00s async, 0.4s sync)
Result: 0/1 passed
Failed: 1 test
[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 consolidation_session.py
Loaded by a script, Weight was not consolidated. Compiled by Mix it was, with the three implementations of lib/:
{:consolidated, [Integer, List, Parcel]}. The test then went wrong in three ways, all printed: Mix warned that “an
implementation for Pallet has no effect”, the type checker of Elixir 1.20, which now knew the implementations, warned
that the test passes a %Pallet{} where “a type that implements the Weight protocol” is expected, and the call raised
Protocol.UndefinedError. Step through the order of events:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The project compiles. Mix compiles lib/weight.ex: the protocol Weight, the struct Parcel and implementations for Parcel, Integer and List.
- Weight is consolidated. With all of lib/ compiled, Mix links Weight to the implementations it knows: {:consolidated, [Integer, List, Parcel]}, and Protocol.consolidated?/1 now says true.
- A test adds an implementation. test/weight_test.exs defines a Pallet struct and implements Weight for it. The file compiles, but Mix warns: "the Weight protocol has already been consolidated, an implementation for Pallet has no effect".
- The type checker knows too. Because the protocol is consolidated, Elixir 1.20 knows which types implement it, and it warns that the test passes a %Pallet{} where a type that implements Weight is expected.
- The test fails. Weight.grams(%Pallet{…}) raises Protocol.UndefinedError, and the message lists what the protocol is implemented for: Integer, List and Parcel.
The Protocol documentation’s Consolidation section describes the ways to give tests an implementation of their own.
What consolidation buys is speed. dispatch.exs makes 1,000,000 calls of Weight.grams/1, counts the work in reductions,
which the VM tallies for every process as it runs code, and times 21 runs, each in a new process, as the lesson on
recursion measured:
# The work and the time of 1,000,000 calls of Weight.grams/1: 1,000 passes over
# 1,000 parcels. The work is counted in reductions; the time is the median of 21
# runs, each in a new process.
defmodule Dispatch do
@runs 21
def run do
parcels = for i <- 1..1_000, do: %Parcel{awb: "AWB#{i}", grams: i}
reductions = in_new_process(fn -> work(parcels) end)
times = Enum.sort(for _ <- 1..@runs, do: in_new_process(fn -> time(parcels) end))
median = Enum.at(times, div(@runs, 2))
IO.puts(
"consolidated: #{Protocol.consolidated?(Weight)}; " <>
"#{:erlang.float_to_binary(reductions / 1_000_000, decimals: 1)} reductions a call; " <>
"median of #{@runs} runs #{:erlang.float_to_binary(median / 1000, decimals: 1)} ms"
)
end
defp work(parcels) do
{:reductions, before} = Process.info(self(), :reductions)
passes(parcels, 1_000, 0)
{:reductions, later} = Process.info(self(), :reductions)
later - before
end
defp time(parcels) do
{micros, _total} = :timer.tc(fn -> passes(parcels, 1_000, 0) end)
micros
end
defp passes(_parcels, 0, total), do: total
defp passes(parcels, n, total), do: passes(parcels, n - 1, Enum.reduce(parcels, total, &(&2 + Weight.grams(&1))))
# Runs fun in a new process and returns what it returned.
defp in_new_process(fun) do
parent = self()
{pid, ref} = spawn_monitor(fn -> send(parent, {self(), fun.()}) end)
receive do
{^pid, result} ->
receive do
{:DOWN, ^ref, :process, ^pid, _reason} -> result
end
end
end
end
Dispatch.run() from session import run
# The same 1,000,000 calls with the protocol loaded as a script, then consolidated by Mix.
run("""
cd courier
elixir -r lib/weight.ex dispatch.exs
mix run dispatch.exs
""") Output
$ cd courier $ elixir -r lib/weight.ex dispatch.exs consolidated: false; 31.0 reductions a call; median of 21 runs 999.8 ms $ mix run dispatch.exs Compiling 1 file (.ex) Generated courier app consolidated: true; 10.0 reductions a call; median of 21 runs 103.1 ms
This output changes from run to run: the times change from run to run with what else the computer is doing; the reductions do not
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dispatch_session.py
The calls through the consolidated protocol did 10.0 reductions each against 31.0, a third of the work, and the median time fell even further, to about a tenth of the script’s in the run above. The Protocol documentation gives this as the reason consolidation exists: “In order to speed up protocol dispatching”.
Key idea
Mix links each protocol to the implementations it knows when the project compiles, which makes every call cheaper and lets the type checker check protocol calls; an implementation that appears after that, such as one in a test file, is ignored.
Checkpoint
Check: Protocol consolidation
Practise: fares for a journey
A city’s rides come as Bus, Metro and lists of both, and a Journey collects them with their total fare. The
starter defines the Fare protocol and the structs, and no implementation. Copy the two files into an empty folder:
# The fare of a ride, in paise, for each kind of ride; and a journey that rides
# can be collected into. Write the implementations.
defprotocol Fare do
@doc "The fare of a ride in paise."
def paise(ride)
end
defmodule Bus do
# 200 paise a kilometre, and never less than 1,000.
defstruct km: 0
end
defmodule Metro do
# 1,000 paise to enter, and 500 for each station travelled.
defstruct stations: 1
end
defmodule Journey do
# The rides in the order they were added, and their total fare in paise.
defstruct rides: [], paise: 0
end
# Fare for Bus, Metro and List (a list of rides costs the sum of their fares),
# and Collectable for Journey, so that Enum.into(rides, %Journey{}) works. Code.require_file("fare.ex", __DIR__)
ExUnit.start(seed: 0)
defmodule FareTest do
use ExUnit.Case
test "a bus ride costs 200 paise a kilometre" do
assert Fare.paise(%Bus{km: 12}) == 2_400
end
test "a short bus ride costs at least 1,000 paise" do
assert Fare.paise(%Bus{km: 3}) == 1_000
end
test "a metro ride costs 1,000 paise plus 500 a station" do
assert Fare.paise(%Metro{stations: 4}) == 3_000
end
test "a list of rides costs the sum of their fares" do
assert Fare.paise([%Bus{km: 12}, %Metro{stations: 4}]) == 5_400
end
test "a ride with no fare raises" do
assert_raise Protocol.UndefinedError, fn -> Fare.paise("auto, 3 km") end
end
test "rides collect into a journey with its total" do
rides = [%Bus{km: 12}, %Metro{stations: 4}]
assert Enum.into(rides, %Journey{}) == %Journey{rides: rides, paise: 5_400}
end
end Run elixir fare_test.exs. With no implementation, every call raises, and only the test that expects a raise passes:
from session import run
# The tests against the starter, which has the protocol and the structs but no implementations.
run("""
cd practice
!elixir fare_test.exs
""") Output
$ cd practice
$ elixir fare_test.exs
Running ExUnit with seed: 0, max_cases: 36
1) test a bus ride costs 200 paise a kilometre (FareTest)
fare_test.exs:7
** (Protocol.UndefinedError) protocol Fare not implemented for Bus (a struct)
Got value:
%Bus{km: 12}
code: assert Fare.paise(%Bus{km: 12}) == 2_400
stacktrace:
fare.ex:3: Fare.impl_for!/1
fare.ex:5: Fare.paise/1
fare_test.exs:8: (test)
2) test a short bus ride costs at least 1,000 paise (FareTest)
fare_test.exs:11
** (Protocol.UndefinedError) protocol Fare not implemented for Bus (a struct)
Got value:
%Bus{km: 3}
code: assert Fare.paise(%Bus{km: 3}) == 1_000
stacktrace:
fare.ex:3: Fare.impl_for!/1
fare.ex:5: Fare.paise/1
fare_test.exs:12: (test)
3) test a metro ride costs 1,000 paise plus 500 a station (FareTest)
fare_test.exs:15
** (Protocol.UndefinedError) protocol Fare not implemented for Metro (a struct)
Got value:
%Metro{stations: 4}
code: assert Fare.paise(%Metro{stations: 4}) == 3_000
stacktrace:
fare.ex:3: Fare.impl_for!/1
fare.ex:5: Fare.paise/1
fare_test.exs:16: (test)
4) test a list of rides costs the sum of their fares (FareTest)
fare_test.exs:19
** (Protocol.UndefinedError) protocol Fare not implemented for List
Got value:
[%Bus{km: 12}, %Metro{stations: 4}]
code: assert Fare.paise([%Bus{km: 12}, %Metro{stations: 4}]) == 5_400
stacktrace:
fare.ex:3: Fare.impl_for!/1
fare.ex:5: Fare.paise/1
fare_test.exs:20: (test)
.
5) test rides collect into a journey with its total (FareTest)
fare_test.exs:27
** (Protocol.UndefinedError) protocol Collectable not implemented for Journey (a struct)
Got value:
%Journey{rides: [], paise: 0}
code: assert Enum.into(rides, %Journey{}) == %Journey{rides: rides, paise: 5_400}
stacktrace:
(elixir 1.20.4) lib/collectable.ex:5: Collectable.impl_for!/1
(elixir 1.20.4) lib/collectable.ex:112: Collectable.into/1
(elixir 1.20.4) lib/enum.ex:1562: Enum.into_protocol/2
fare_test.exs:29: (test)
Finished in 0.1 seconds (0.1s on load, 0.00s async, 0.07s sync)
Result: 1/6 passed
Failed: 5 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
Write defimpl Fare for Bus, Metro and List, each one line; the list adds the fares of its rides by calling
Fare.paise/1 on each. Then write defimpl Collectable, for: Journey: into/1 returns the journey and a collector
that adds each ride and its fare on {:cont, ride} and returns the journey on :done. Keep the rides in the order
they came. A finished version passes all six:
from session import run
# The same tests against finished implementations.
run("""
cd finished
cp ../practice/fare_test.exs .
elixir fare_test.exs
""") Output
$ cd finished $ cp ../practice/fare_test.exs . $ elixir fare_test.exs Running ExUnit with seed: 0, max_cases: 36 ...... Finished in 0.1 seconds (0.1s on load, 0.00s async, 0.03s 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
Then answer the self-check. Its questions come one at a time, the idea you have answered worst so far first, and each choice explains why it is right or wrong once you pick it:
Practice that adapts to you
Practise this lesson
14 questions about 5 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 a protocol in Elixir, and when do you use one instead of a case on the value’s type?
A protocol is a set of functions that each type implements for itself with defimpl, and a call runs the
implementation for the type of its first argument: Weight.grams(parcel) runs the one for Parcel, and a type with
no implementation raises Protocol.UndefinedError. A case or a multi-clause function does the same job when one
module owns every type it handles. A protocol is the better choice when other code must be able to add types: anyone
can implement it, in their own module, without editing yours, which is how Enumerable, String.Chars and Inspect work
for every type in the language. In a Mix project protocols are consolidated when the project compiles, so their calls
are cheap; for a closed set of types that will never grow, plain pattern matching stays simpler.
Two questions from code that prints and dispatches:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Your library defines an Exporter protocol. In an application that uses it, a test file defines a struct and implements Exporter for it, and the test fails with Protocol.UndefinedError although the defimpl is right there. The compiler printed a warning too. Explain what happens, and give two ways to let the tests have their implementation.
-
Mid to senior
A payment worker keeps a %Session{} struct with an API token as its state, and when it crashes the crash report prints the whole state into production logs. What do you change so that tokens never reach the logs, what does your change not protect, and how do you test it?
Key takeaways
- A protocol is defined once with
defprotocoland implemented per type withdefimpl, and every call, even one made inside another implementation, runs the implementation for the type of its first argument. - A struct never uses the map’s implementation: it needs its own, a
@derivethat points it at theAnyimplementation, or@fallback_to_any, which turns a missing implementation into a silent default. - String.Chars decides what interpolation prints, and a derived Inspect with
only:keeps fields out ofinspect, IEx and error messages, though the fields stay in the struct. - Collectable’s
into/1puts items into a struct forEnum.into/2andfor … into:, and Enumerable’sreduce/3, with fast answers fromcount/1andmember?/2where possible, takes them out for every Enum function. - Mix consolidates protocols when a project compiles: calls then did a third of the work, the type checker checks them, and an implementation defined later, as in a test file, has no effect.
Terms in this lesson
- @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.
- protocol
- A set of functions that dispatch on the type of their first argument: each type, such as a struct, List or Integer, brings its own implementation with defimpl. A call for a type without one raises Protocol.UndefinedError, unless the protocol falls back to Any.
- protocol consolidation
- The step at the end of compiling a Mix project that links each protocol to the implementations known then, so its calls dispatch faster. An implementation defined later, such as in a test file, has no effect.
References
- Elixir guide: Protocols (The Elixir Team (hexdocs.pm))
- Protocol (Elixir): consolidation, fallback to Any, derive (The Elixir Team (hexdocs.pm))
- Kernel (Elixir): defprotocol/2, defimpl/3 and deriving (The Elixir Team (hexdocs.pm))
- String.Chars (Elixir) (The Elixir Team (hexdocs.pm))
- Inspect (Elixir): deriving (The Elixir Team (hexdocs.pm))
- Enumerable (Elixir) (The Elixir Team (hexdocs.pm))
- Collectable (Elixir) (The Elixir Team (hexdocs.pm))
- erlang (ERTS): process_info/2 (reductions) (Erlang/OTP (erlang.org))
Report a problem with this lesson
Kept only in this browser. Your Learn progress