Skip to content
Protocols
step 1/7

Reading — step 1 of 7

Learn

~2 min readPolymorphism

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 worksdef 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…