Reading — step 1 of 7
Learn
Elixir's protocols are how dispatch happens for different data types. Like type classes in Haskell, traits in Rust, or interfaces with multiple-dispatch in Java. Programming Elixir treats them as one of the language's signature features.
What's a protocol?
A contract that types can implement:
defprotocol Size do
@doc "Returns the size of the data structure"
def size(data)
end
No implementation here — just the function signature. Now data types implement it:
defimpl Size, for: BitString do
def size(string), do: byte_size(string)
end
defimpl Size, for: List do
def size(list), do: length(list)
end
defimpl Size, for: Map do
def size(map), do: map_size(map)
end
Usage:
Size.size("hello") # 5
Size.size([1, 2, 3]) # 3
Size.size(%{a: 1, b: 2}) # 2
The runtime dispatches based on the value's type.
Built-in protocols
Elixir's stdlib uses protocols heavily:
Enumerable # for Enum.* — your type can be enumerable
Collectable # for Enum.into and for/into
Inspect # for IO.inspect — controls pretty-printing
String.Chars # for to_string/1 — controls string conversion
List.Chars # for to_charlist/1
Implement Enumerable for your custom collection and Enum.map(your_thing, fn) works.
Default fallback — Any
Provide a fallback for unmatched types:
defimpl Size, for: Any do
def size(_), do: 0
end
In the protocol declaration, allow Any:
defprotocol Size do
@fallback_to_any true
def size(data)
end
Without @fallback_to_any true, calling Size.size on a non-implemented type raises Protocol.UndefinedError.
Implementing for structs
Your custom struct can implement any protocol:
defmodule User do
defstruct [:name, :age]
end
defimpl String.Chars, for: User do
def to_string(%User{name: name, age: age}) do
"User(#{name}, #{age})"
end
end
to_string(%User{name: "Ada", age: 36})
# "User(Ada, 36)"
Now "#{user}" interpolation works. Like overriding toString in Java or str in Python.
When to use protocols
Yes:
- Polymorphism over data shapes (different containers, different value types)
- Extension points your library exposes ("users can implement Inspect for their type")
- Built-in protocol implementations (always implement Inspect for custom data — much better debug output)
No:
- Single-type behavior — just write a function
- When pattern matching in one function head suffices — that's simpler
- Performance-critical hot paths — protocol dispatch has overhead vs direct calls
Consolidation — production performance
In a Mix project, protocols are CONSOLIDATED at compile time — the runtime dispatch becomes a fast lookup. Without consolidation (e.g., in IEx), they're slower. Mix releases automatically consolidate.
Common mistakes
- Forgetting
@fallback_to_any true— protocol calls error on types you didn't implement. - Protocols vs behaviours confusion — protocols are for VALUE-type dispatch. Behaviours (next lesson) are CONTRACTS for module implementation. Different tools.
- Implementing for Any greedily — defeats the type-safety win. Make sure Any fallback is what you want.
- Not implementing Inspect for custom data — IO.inspect output looks ugly. Custom Inspect is small and pays back forever.
- Reaching for protocols when pattern matching works —
def render(%User{} = u)is simpler than a Renderer protocol with one impl.
Discussion
Ask a question, share an insight, or help someone who’s stuck.
Sign in to post a comment or reply.
Loading…