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

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.

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

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:

weight.exs Elixir · 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()
Four kinds of item, one function Python · weight_session.py
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:

Interactive · message by message One call of Weight.grams/1 on a list, dispatched item by item Step through the call. The middle column is the protocol, which picks an implementation by the type of the value it gets.
One call of Weight.grams/1 on a list, dispatched item by item, step 9 of 9. Messages so far: Main.run/0 to Weight: grams([…]); Weight to The implementations: Weight.List; The implementations to Weight: grams(parcel); Weight to The implementations: Weight.Parcel: 1200; The implementations to Weight: grams(envelope); Weight to The implementations: Weight.Envelope: 70; The implementations to Weight: grams(250); Weight to The implementations: Weight.Integer: 250; The implementations to Main.run/0: 1520.Main.run/0grams of the listWeightpicks by typeThe implementationsParcel, Envelope, …grams([…])Weight.Listgrams(parcel)Weight.Parcel: 1200grams(envelope)Weight.Envelope: 70grams(250)Weight.Integer: 2501520

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

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. A parcel weighs its grams. The parcel is a %Parcel{} struct, so the protocol runs Weight.Parcel, which answers its grams field: 1,200.
  5. The envelope goes back. Next the envelope, again through the protocol.
  6. An envelope counts sheets. Weight.Envelope adds 10 g for the envelope and 5 g for each of its 12 sheets: 70.
  7. A bare number. The last item, the number 250, goes back through the protocol too.
  8. An integer is grams already. It is an integer, so the protocol runs Weight.Integer, which answers the number itself: 250.
  9. 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

  1. Question 1 of 2 With the implementations of weight.exs, what is Weight.grams([%Envelope{sheets: 2}, 100])?

    Type a number.

    A hint

    Weight.List sends each item back through the protocol.

    Show the answer: Question 1 of 2

    Answer: 120 grams

    Weight.List calls Weight.grams/1 on each item. The envelope goes to Weight.Envelope, 10 + 5 × 2 = 20 g, and 100 to Weight.Integer, 100 g, so the list weighs 120 g.

  2. Question 2 of 2 What does Weight.impl_for("2 kg") return in weight.exs?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: nil, because no implementation exists for strings

    Every protocol has impl_for/1, which names the implementation a value would get, or nil. The call itself then raises Protocol.UndefinedError for a value whose type has none.

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:

derive.exs Elixir · derive.exs
# 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()
A map, a struct that derives Weight, and one that does not Python · derive_session.py
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:

fallback.exs Elixir · fallback.exs
# 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()
A fallback that weighs a list as nothing Python · fallback_session.py
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

  1. Question 1 of 2 Weight has implementations for Map and Any, and no fallback. What does Weight.grams(%Box{grams: 400}) do, for a struct that derives nothing?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It raises Protocol.UndefinedError

    For protocols a struct is its own type. It gets an implementation by its own defimpl, by @derive, which points it at Any, or by a protocol that falls back to Any; otherwise the call raises.

  2. Question 2 of 2 In fallback.exs, 2,500 g cost 12,000 paise. The list of 1,200 g and 1,300 g fell back to Any and paid the minimum. How many paise short was it?

    Type a number.

    A hint

    The minimum fee is what a weight of 0 pays.

    Show the answer: Question 2 of 2

    Answer: 8000 paise

    The list weighed 0 through the fallback, so it paid the 4,000 paise minimum instead of 12,000 for its real 2,500 g: 8,000 paise short, with no error to say so.

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:

printing.exs Elixir · printing.exs
# 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()
A parcel for people, a courier for logs Python · printing_session.py
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

  1. Question 1 of 2 Courier derives Inspect but does not implement String.Chars. What does "Courier: #{ravi}" do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It raises Protocol.UndefinedError for String.Chars

    String.Chars is for text meant for people, and a struct has it only if its module implements it. The error message itself showed the courier through the derived Inspect, without the token.

  2. Question 2 of 2 Courier has @derive {Inspect, only: [:name]}. Which of these show the api_token?

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • IO.inspect(ravi, structs: false)
    • Map.from_struct(ravi)

    A derived Inspect changes how the usual ways of printing show a struct. The data is unchanged, so anything that reads the fields or prints the plain map still sees the token.

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:

bag.exs Elixir · bag.exs
# 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()
A bag filled with Enum.into/2 and for, then read with Enum Python · bag_session.py
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:

Interactive · stage by stage Enum.into/2 filling a bag through Collectable Step through Enum.into(orders, %Bag{}). Each stage is one part of the Collectable contract; the sub-labels show the counts so far.
Enum.into/2 filling a bag through Collectable, step 6 of 6. Showing Collectable.into(bag), collector, {:cont, item} (chai 3, coffee 1, samosa 1), collector, :done, Enum.into/2 returns. In focus: Enum.into/2 returns.Collectable.into(bag){bag, collector}collector, {:cont, item}chai 3, coffee 1, samosa 1collector, :donereturns the bagEnum.into/2 returnsthe filled bag%Bag{…}

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

Every step as text
  1. 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.
  2. 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.
  3. Two more. "coffee" and "chai" follow, one call each, and each call returns the new bag, which the next call receives.
  4. The last two. "samosa" and a third "chai" make five calls in all, one for each order.
  5. 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.
  6. 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

  1. Question 1 of 2 In a Collectable implementation, what must the collector return when it is called with :done?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The finished collectable, here the filled bag

    Enum calls the collector with {:cont, item} for every item and then with :done, and whatever it returns then is the result of Enum.into/2.

  2. Question 2 of 2 A struct's Enumerable.count/1 returns {:error, __MODULE__}. What does Enum.count/1 do with it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It counts by going through every item with reduce/3, which takes time in proportion to the size

    count/1, member?/2 and slice/1 are shortcuts. Answer them when the struct can do better than going through every item, as the bag does from its counts.

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:

courier/test/weight_test.exs Elixir · courier/test/weight_test.exs
# 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
The protocol loaded by a script, compiled by Mix, then tested Python · consolidation_session.py
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:

Interactive · stage by stage mix test in the courier project, in order Step through one mix test. Each stage is a step Mix takes; the sub-labels say what each one found.
mix test in the courier project, in order, step 5 of 5. Showing Compile lib/, Consolidate protocols (Integer, List, Parcel), Compile the tests (Pallet: no effect), Run the test (Protocol.UndefinedError). In focus: Run the test.Compile lib/Weight and 3 implementationsConsolidate protocolsInteger, List, ParcelCompile the testsPallet: no effectRun the testProtocol.UndefinedErrorgrams(%Pallet{…})

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

Every step as text
  1. The project compiles. Mix compiles lib/weight.ex: the protocol Weight, the struct Parcel and implementations for Parcel, Integer and List.
  2. 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.
  3. 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".
  4. 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.
  5. 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:

courier/dispatch.exs Elixir · courier/dispatch.exs
# 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()
A million calls, with the protocol loaded by a script and consolidated by Mix Python · dispatch_session.py
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

  1. Question 1 of 2 In a Mix project, a test file defines a Pallet struct and defimpl Weight, for: Pallet. What happens when the test calls Weight.grams(%Pallet{boxes: [100, 250]})?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It raises Protocol.UndefinedError; Mix had warned that the implementation has no effect

    Consolidation links a protocol to the implementations known when the project compiles. A defimpl in a test file comes later, so the consolidated protocol never looks for it.

  2. Question 2 of 2 What does Protocol.consolidated?(Weight) return when Weight is loaded by a script, with elixir -r?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: false

    Consolidation is a step of compiling a Mix project. A protocol loaded by a script is used without it, which is why its calls did about three times the work.

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:

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

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

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

  1. Question 1 of 14 How does a call such as Weight.grams(item) choose the implementation it runs?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: By the type of its first argument

    Each type has its own implementation, and the call runs the one for the type of its first argument: a parcel's for a parcel, a list's for a list.

  2. Question 2 of 14 With the implementations of weight.exs, what does this do?

    Read the code, then choose one answer.

    Weight.grams([250, "2 kg"])
    Show the answer: Question 2 of 14

    Answer: It raises Protocol.UndefinedError for BitString, from inside Weight.List

    The list's implementation calls Weight.grams/1 on each item, so every item must have an implementation of its own. The first that has none stops the call.

  3. Question 3 of 14 Another team's application adds a Pallet struct and needs Weight.grams/1 to weigh pallets. What do they do?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: Write defimpl Weight, for: Pallet in their own code

    This is what protocols are for: the courier's code defines the contract, and each type, wherever it is defined, brings its own implementation.

  4. Question 4 of 14 Weight has an Any implementation. What does @derive Weight before defstruct give the struct?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: An implementation of its own that points at the Any implementation

    @derive asks the protocol for an implementation for this struct. Weight cannot generate one itself, so the derived implementation points at Any.

  5. Question 5 of 14 Why does the Elixir guide say that raising, not falling back to Any, is right for most protocols?

    Choose one answer.

    Show the answer: Question 5 of 14

    Answer: A fallback turns a forgotten implementation into a wrong answer that nobody notices

    A missing implementation is a bug, and an error points straight at it. A default answer hides it until the numbers are wrong somewhere downstream.

  6. Question 6 of 14 Which protocol does "Booked #{parcel}" use to turn the parcel into text?

    Choose one answer.

    Show the answer: Question 6 of 14

    Answer: String.Chars, through to_string/1

    Implement String.Chars for a struct that has an obvious text for people, as the parcel does; leave it out when no single text is right, and interpolation will raise.

  7. Question 7 of 14 A User struct has id and email, and @derive {Inspect, only: [:id]}. What does inspect(%User{id: 1, email: "a@example.com"}) return?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: #User<id: 1, ...>

    The Inspect documentation says a struct printed with only: or except: uses the #User<…> notation, because it can no longer be pasted back as valid Elixir.

  8. Question 8 of 14 Courier derives Inspect with only: [:name]. A handler answers JSON.encode!(Map.from_struct(courier)). Does the token reach the client?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: Yes, because the derived Inspect changes only printing, and the map holds every field

    Each protocol has its own rule: Inspect for printing, JSON.Encoder for JSON. Hide a secret in each, and never turn a struct with a secret into a plain map on its way out.

  9. Question 9 of 14 Which protocol does Enum.into(orders, %Bag{}) need the bag to implement?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: Collectable

    The first argument of Enum.into/2 is read through Enumerable and the second filled through Collectable, which is why the bag implements both.

  10. Question 10 of 14 Which Enumerable callback can every Enum function fall back on?

    Choose one answer.

    Show the answer: Question 10 of 14

    Answer: reduce/3

    With reduce/3 written, Enum can do everything; count/1, member?/2 and slice/1 only make some functions faster.

  11. Question 11 of 14 With bag.exs, what is Enum.count(bag) after bag = Enum.into(["tea", "tea", "coffee"], %Bag{}) and bag = for x <- ["tea"], into: bag, do: x?

    Type a number.

    A hint

    count/1 adds up the counts.

    Show the answer: Question 11 of 14

    Answer: 4 items

    Three items went in through Enum.into/2 and one through the comprehension: tea 3, coffee 1. count/1 sums the counts, 4.

  12. Question 12 of 14 What does protocol consolidation do?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: It links a protocol to the implementations known when a Mix project compiles, so calls dispatch faster

    Consolidation runs when a Mix project compiles, after all its implementations are known, and makes the protocol dispatch straight to them.

  13. Question 13 of 14 The script's calls did 31.0 reductions each and the consolidated calls 10.0. About how many times the work did the script do?

    Type a number.

    Show the answer: Question 13 of 14

    Answer: 3.1 times (anything from 3 to 3.2 counts)

    31.0 ÷ 10.0 = 3.1, about three times the work for the same million calls. The time differed even more: the consolidated median was about a tenth of the script's.

  14. Question 14 of 14 Why could Elixir 1.20 warn about Weight.grams(%Pallet{…}) in the courier project, while a script found the problem only when it ran?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: In the project the protocol was consolidated, so the type checker knew which types implement it

    A consolidated protocol knows its implementations, and Elixir 1.20 uses that to check the types given to protocol calls, so a type without an implementation is flagged before anything runs.

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.

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

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

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

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A protocol is defined once with defprotocol and implemented per type with defimpl, 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 @derive that points it at the Any implementation, 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 of inspect, IEx and error messages, though the fields stay in the struct.
  • Collectable’s into/1 puts items into a struct for Enum.into/2 and for … into:, and Enumerable’s reduce/3, with fast answers from count/1 and member?/2 where 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

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.