Protocols
Protocols are how Elixir does polymorphism. They let one function name mean different things depending on the type of data passed to it. Enum.map/2 works on lists, maps, ranges, MapSets, streams, and anything you write yourself — not because each one inherits from some base class, but because each one has an implementation of the Enumerable protocol that Enum.map/2 looks up at runtime.
If you have written Rust, this is essentially the trait system. If you have written Haskell, it is typeclasses. If you have written Java or Ruby, this is the interface concept but with a critical inversion: the data type and the implementation are decoupled. You can implement a protocol for somebody else's struct without modifying their code, and they can ship a struct without knowing which protocols you will later implement for it.
The Mechanics
A protocol is a contract. defprotocol declares which functions exist, and defimpl provides the actual code for a specific type.
defprotocol Size do
@doc "Returns the size of the given data structure"
def size(data)
end
defimpl Size, for: BitString do
def size(string), do: byte_size(string)
end
defimpl Size, for: Map do
def size(map), do: map_size(map)
end
defimpl Size, for: Tuple do
def size(tuple), do: tuple_size(tuple)
end
Now Size.size("hello") returns 5, Size.size(%{a: 1, b: 2}) returns 2, and Size.size({1, 2, 3}) returns 3. Same function name, completely different code paths, picked at runtime by looking at the first argument's type.
The dispatch table the compiler builds is essentially type → module. When you call Size.size(x), Elixir checks the type of x and calls Size.BitString.size(x), Size.Map.size(x), or whichever implementation matches. Each defimpl generates a real module with that name — you can call Size.BitString.size("hi") directly if you want to, though you rarely should.
What Types Can You Implement For
Elixir recognizes seven built-in types for protocol dispatch, plus any struct.
Atom, BitString, Float, Function, Integer, List, Map, PID,
Port, Reference, Tuple, and Any
You can implement a protocol for any of these, or for a specific struct like User or MapSet. Structs are the interesting case because they are how you give your own data types a stable identity that protocols can dispatch on.
defmodule Money do
defstruct [:amount, :currency]
end
defimpl Size, for: Money do
def size(%Money{}), do: 2
end
The protocol dispatch sees the __struct__ field on the map and routes to Size.Money instead of the generic Size.Map.
The Built-in Protocols You Will Actually Use
Elixir ships a small set of protocols that the rest of the standard library is built on. Understanding what they are is more important than memorizing every function in them.
Enumerable is how Enum and Stream work. Anything that implements Enumerable.reduce/3 can be passed to every function in Enum. Lists, maps, ranges, MapSets, streams, and File.stream!/1 all do. We will implement this for a custom structure in the next chapter.
Collectable is the inverse of Enumerable. It is what Enum.into/2 and for comprehensions with :into use to collect values into a structure. Maps, lists, MapSets, and IO streams implement it. If you build a custom collection type that should accept Enum.into(values, %MyContainer{}), you implement Collectable.
Inspect controls how a value appears in IEx, in IO.inspect/2, and in ~p Logger interpolations. The default for any struct dumps every field with its full struct name, which is fine for small types and terrible for anything with a binary blob, a password hash, or a deeply nested cache.
String.Chars is what to_string/1 and "#{value}" interpolation use. If a value does not implement String.Chars, you cannot put it inside a string with #{}. Atoms, numbers, binaries, lists of integers (charlists), and a handful of others implement it. Maps, tuples, and arbitrary structs deliberately do not — you have to opt in.
List.Chars is the same idea for to_charlist/1. Rare in modern Elixir code, but it shows up when interfacing with Erlang libraries that expect charlists rather than binaries.
You will spend most of your protocol energy on Enumerable, Collectable, and Inspect. The other two come up occasionally.
Implementing Inspect: The Most Common Real-World Use
Every codebase ends up with at least one struct that prints horribly by default. Authentication tokens, encrypted payloads, large embedded structs, anything with a password_hash field. The default Inspect for structs dumps everything, which is both noisy and a real security problem when those values land in logs.
defmodule User do
defstruct [:id, :email, :password_hash, :api_token, :preferences]
end
%User{id: 1, email: "alice@example.com", password_hash: "$argon2id$...",
api_token: "sk_live_abcd1234...", preferences: %{theme: :dark}}
In IEx, that prints with the full hash and token visible. In a production log, that is now a credential leak waiting to happen. The fix is a custom Inspect implementation.
defimpl Inspect, for: User do
import Inspect.Algebra
def inspect(user, opts) do
concat([
"#User<",
to_doc(%{id: user.id, email: user.email}, opts),
">"
])
end
end
Now IO.inspect(user) prints #User<%{email: "alice@example.com", id: 1}> and the sensitive fields never appear. Phoenix's Plug.Conn struct, Ecto's Schema structs, and Plug.Crypto's opaque types all do this same thing. It is the single most underused protocol in the language given how cheap it is to implement.
The Inspect.Algebra module gives you concat/1, group/1, nest/2, and to_doc/2 for building pretty-printed output that wraps cleanly at terminal width. For simple cases you can also derive a redacted inspector by listing fields to omit:
defmodule User do
@derive {Inspect, except: [:password_hash, :api_token]}
defstruct [:id, :email, :password_hash, :api_token, :preferences]
end
That gets you safe inspection without writing the implementation by hand. Ecto schemas in Phoenix codebases lean on this heavily.
Compared to Rust Traits and Haskell Typeclasses
The closest analogues in other languages help fix the mental model. Rust's traits and Haskell's typeclasses both let you say "values of these types share an operation," and the implementation is decoupled from the type definition. Elixir protocols do the same thing, with one critical difference: dispatch is fully dynamic.
In Rust, when you call value.size() on a type that implements Size, the compiler uses monomorphization or vtables to resolve the dispatch at compile time. The type system guarantees the implementation exists before the binary is built. In Haskell, typeclass instances are resolved by the compiler too — there is no runtime type tag to check, because the elaborator has already inserted the right dictionary into the call site.
Elixir does none of this. The compiler does not know which types will be passed to Size.size/1, and it does not care. Each call looks at the actual runtime value and dispatches. This is what gives Elixir its hot-reload story, its remote IEx debugging, and its ability to receive arbitrary terms over a distributed cluster — but it also means a missing implementation is something you discover by crashing.
The orphan-instance problem in Haskell — where two packages can define conflicting Show instances for the same type — exists in Elixir too. If your library defines Inspect for MapSet and another library also defines it, whichever loads last wins. The community works around this by convention: implement protocols for types you own, or for types defined by the protocol you are implementing against, but not for the intersection of two third-party libraries' types.
How Dispatch Actually Works
When you call Size.size(value), the protocol module first looks at value's type. For most types this is a constant-time check. For structs, it reads the __struct__ field and looks up Size.<StructName>. If no implementation exists, the call raises Protocol.UndefinedError.
iex> Size.size(:foo)
** (Protocol.UndefinedError) protocol Size not implemented for :foo of type Atom
This is the trade-off compared to typeclasses in Haskell: dispatch is a runtime decision, not a compile-time one. If you call a protocol function on a value with no implementation, you find out by crashing in production, not by failing the build. The dialyzer can help catch some cases, but in practice you find these by running code.
The upside is that protocols compose with everything dynamic about Elixir. You can pass values through hot reloading, across nodes in a cluster, through IO.inspect calls in a remote iex session, and the dispatch still works.
Where Protocols Live in the Generated Code
A defprotocol block is sugar for generating a real module. The protocol module exports each declared function plus an __protocol__/1 introspection function, and at runtime each call routes through Protocol.assert_impl!/2 to find the implementation module.
You can see this by checking what the compiler produces:
iex> Size.__protocol__(:functions)
[size: 1]
iex> Size.__protocol__(:impls)
{:consolidated, [BitString, Map, Tuple, Money]}
Size.BitString, Size.Map, Size.Tuple, and Size.Money are all real modules. They are not magic — you can Size.BitString.size("hello") directly and it will work. The dispatcher module just picks one of these to call based on the type of its argument.
This concreteness matters when you are debugging. A (Protocol.UndefinedError) protocol Size not implemented for X trace points at the dispatcher, but the actual code lives in Size.<TypeName>. Tools like Erlang's :cover, ExUnit's coverage reporter, and :dbg tracing all show you the implementation module, not the protocol module. When you see Inspect.Map.inspect/2 in a stack trace, that is the Inspect implementation for maps — exactly what you would expect once you know how the modules are named.
Why This Beats Inheritance
In an object-oriented language, you usually add a new operation by either modifying the class hierarchy (often impossible if you do not own the code) or by overloading on type (which scatters the logic across files). Protocols invert this. The data is fixed; the operations are extensible. Anyone can add a new operation to existing types without touching the original definitions.
This is why every library that wants to plug into Enum just implements Enumerable. Ecto's Repo.stream/1 returns a stream that implements Enumerable. Phoenix's Plug.Conn implements Collectable so you can Enum.into response chunks. Discord's libraries implement custom protocols for their event types. None of these required changes to Elixir itself.
Rust traits work similarly with coherence rules. Haskell typeclasses do too, with the famous orphan-instance problem. Elixir's protocols have the same property and the same caveat: if two libraries both implement Inspect for MapSet, the last one loaded wins, and that is a real runtime behavior, not a compile error.
A Second Real Example: A Currency-Aware Money Type
Custom Inspect is the easiest win, but protocols are also how you make your domain types feel native. A Money struct that participates in to_string/1 and looks clean in IEx output is far nicer to work with than one that requires special accessors.
defmodule Money do
defstruct [:amount, :currency]
def new(amount, currency), do: %Money{amount: amount, currency: currency}
end
defimpl Inspect, for: Money do
def inspect(%Money{amount: amount, currency: currency}, _opts) do
formatted = :erlang.float_to_binary(amount / 100, decimals: 2)
"#Money<#{formatted} #{currency}>"
end
end
defimpl String.Chars, for: Money do
def to_string(%Money{amount: amount, currency: currency}) do
"#{:erlang.float_to_binary(amount / 100, decimals: 2)} #{currency}"
end
end
Now in IEx:
iex> Money.new(1995, "USD")
#Money<19.95 USD>
iex> "Total: #{Money.new(4250, "EUR")}"
"Total: 42.50 EUR"
Without these implementations, the first call would dump %Money{amount: 1995, currency: "USD"} and the second would raise Protocol.UndefinedError because string interpolation requires String.Chars. With them, the type behaves like any other primitive in the language. This is the same pattern Ecto's Decimal library uses, and it is why values from Phoenix forms and Ecto schemas slot into templates without extra ceremony.
Common Pitfalls
Forgetting that protocols only dispatch on the first argument. Unlike multimethods in Clojure or Julia, Elixir protocols pick the implementation based on the type of the first argument only. If you want to dispatch on more, you need either nested protocol calls or a pattern-matching function instead of a protocol.
Implementing a protocol for Any without thinking. It is a tempting catch-all, but it changes how the protocol behaves for every type you have not explicitly handled — including future types from other libraries. Use it deliberately, not as a shortcut.
Confusing protocols with behaviours. Both feel like "interfaces" but they answer different questions. Protocols dispatch on the type of a value. Behaviours dispatch on the name of a module. We cover behaviours in the third chapter of this topic, and the distinction matters every time you reach for either.
Putting business logic in Inspect. It is just a print formatter. Code that depends on inspect/1 output is fragile and breaks every time someone tweaks the formatting. If you need a programmatic representation, write a real function.
Assuming protocols are zero-cost. They are fast — usually a single map lookup — but they are not free, and they involve a runtime dispatch that the compiler cannot inline. For inner loops on hot paths, a pattern-matching function in a single module can be faster than the equivalent protocol call.
Key Takeaways
- Protocols are Elixir's polymorphism mechanism: one function name, many implementations, dispatched at runtime based on the type of the first argument.
defprotocoldeclares the contract;defimpl Protocol, for: Typeprovides an implementation. Eachdefimplbecomes a real module the compiler generates.- The five built-in protocols you should know:
Enumerable,Collectable,Inspect,String.Chars,List.Chars.Enumerableis by far the most consequential. - A custom
Inspectimplementation is the cheapest protocol win in any codebase — it cleans up IEx output and prevents credentials from leaking into logs. - The model is closer to Rust traits and Haskell typeclasses than to OO interfaces: data and operations are decoupled, and either side can be added without modifying the other.
- Dispatch happens at runtime. Missing implementations are runtime errors, not compile errors. Treat protocol calls like you treat any other dynamic dispatch.