Understanding protocols in Elixir: a comprehensive guide
How Elixir protocols make polymorphism feel like design work. Less ceremony, more clarity, and a path away from giant conditionals.
Elixir has no classes, so there is no base class to override when different kinds of data need different behavior. It has protocols. A protocol names one or more functions. Each data type provides its own version with defimpl, and Elixir picks the right version at runtime based on the value you pass in. If you know interfaces from other languages, it’s a similar idea, except you can add an implementation for a type you didn’t write.
Treat protocols as design tools and the code grows without turning into a wall of conditionals.
This post builds one protocol from scratch, a tiny JSON encoder, and then uses the same idea for validation. The encoder is only for teaching. In a real app, use Jason or the JSON module built into Elixir 1.18 and later. Both use a protocol to let your types plug in, which is why the encoder makes a good example. Along the way I’ll point out where protocols help and where a plain function is the better call.
The setup: one encoder for everything
Every API eventually needs to turn arbitrary data into JSON. Without protocols, you end up with a sprawling function that has to know about every type in the system:
defmodule JSONEncoder do
def encode(data) when is_binary(data), do: "\"#{data}\""
def encode(data) when is_integer(data), do: Integer.to_string(data)
def encode(data) when is_boolean(data), do: Atom.to_string(data)
def encode(data) when is_list(data), do: "[#{Enum.map_join(data, ",", &encode/1)}]"
# More and more clauses for every possible type...
end
It works, but it has problems:
- One module owns everything. Every new type means editing
JSONEncoderagain. - Outsiders can’t extend it. Third-party structs can’t plug in without forking.
- It knows too much. The function has to understand every domain concept to do its job.
- It gets long fast. The pattern matches stack up forever.
Protocols split that one function up by type.
Protocols: contracts, not conditionals
Define the contract once, then let each type answer for itself:
# Step 1: Define the contract
defprotocol JSONEncodable do
@doc "Converts a value to its JSON string representation"
def to_json(value)
end
# Step 2: Implement for basic types
defimpl JSONEncodable, for: BitString do
def to_json(string), do: "\"#{String.replace(string, "\"", "\\\"")}\""
end
defimpl JSONEncodable, for: Integer do
def to_json(integer), do: Integer.to_string(integer)
end
defimpl JSONEncodable, for: Atom do
def to_json(true), do: "true"
def to_json(false), do: "false"
def to_json(nil), do: "null"
def to_json(atom) do
atom
|> Atom.to_string()
|> JSONEncodable.to_json()
end
end
defimpl JSONEncodable, for: List do
def to_json(list) do
items = Enum.map_join(list, ",", &JSONEncodable.to_json/1)
"[#{items}]"
end
end
Two type names may look odd. Elixir strings are binaries, and protocols see binaries as BitString, so that is the type a string implementation targets. Also, there is no Boolean type to implement for. In Elixir, true, false, and nil are atoms, so they share the Atom implementation. Pattern matching on the specific values comes first, and any other atom becomes a JSON string.
Each call now goes to the implementation for its type:
iex> JSONEncodable.to_json("hello")
"\"hello\""
iex> JSONEncodable.to_json(42)
"42"
iex> JSONEncodable.to_json(true)
"true"
iex> JSONEncodable.to_json([1, "hello", false])
"[1,\"hello\",false]"
The real payoff shows up when you need to add types you didn’t plan for:
# In a completely different module, even a different library:
defimpl JSONEncodable, for: Date do
def to_json(date) do
date
|> Date.to_iso8601()
|> JSONEncodable.to_json()
end
end
defimpl JSONEncodable, for: DateTime do
def to_json(datetime) do
datetime
|> DateTime.to_iso8601()
|> JSONEncodable.to_json()
end
end
Look at what both implementations do. They convert the value into something the protocol already knows, a string, and then delegate. No special cases needed.
iex> JSONEncodable.to_json(~D[2024-11-10])
"\"2024-11-10\""
Nothing in the original code changes. The protocol gets extended for new types as they show up, even from code you don’t own.
Maps: the foundation for structs
To be useful, the protocol needs to handle maps. Most real data ends up in one:
defimpl JSONEncodable, for: Map do
def to_json(map) when map == %{}, do: "{}"
def to_json(map) do
pairs =
map
|> Enum.map(fn {key, value} ->
key_json = JSONEncodable.to_json(to_string(key))
value_json = JSONEncodable.to_json(value)
"#{key_json}:#{value_json}"
end)
|> Enum.join(",")
"{#{pairs}}"
end
end
Each value goes back through JSONEncodable.to_json/1, so nesting works with no extra code:
iex> data = %{"name" => "Alice", "age" => 30, "active" => true}
iex> JSONEncodable.to_json(data)
"{\"active\":true,\"age\":30,\"name\":\"Alice\"}"
The keys come out in a different order than you wrote them. Elixir maps don’t keep insertion order, which is fine for JSON objects but worth knowing before you write a test that compares strings.
Now your real domain types
Structs don’t use the Map implementation automatically. A struct only gets a protocol implementation when you give it one. That is a good thing, because it makes you decide what each type exposes:
defmodule User do
defstruct [:id, :name, :email, :role, :created_at]
end
defmodule Article do
defstruct [:id, :title, :content, :author_id, :published_at, :tags]
end
Each implementation picks its fields and hands a plain map to the Map implementation:
defimpl JSONEncodable, for: User do
def to_json(%User{id: id, name: name, email: email, role: role}) do
%{
id: id,
name: name,
email: email,
role: role
}
|> JSONEncodable.to_json()
end
end
defimpl JSONEncodable, for: Article do
def to_json(%Article{id: id, title: title, author_id: author_id, published_at: published_at, tags: tags}) do
%{
id: id,
title: title,
author_id: author_id,
published_at: published_at,
tags: tags
}
|> JSONEncodable.to_json()
end
end
User leaves out created_at, and Article leaves out content. The struct decides what goes over the wire, and the encoder doesn’t need to know.
Putting it to work:
iex> user = %User{id: 1, name: "Alice", email: "[email protected]", role: "admin"}
iex> JSONEncodable.to_json(user)
"{\"id\":1,\"name\":\"Alice\",\"email\":\"[email protected]\",\"role\":\"admin\"}"
iex> article = %Article{id: 1, title: "Protocols Guide", author_id: 1, published_at: nil, tags: ["elixir", "tutorial"]}
iex> JSONEncodable.to_json(article)
"{\"id\":1,\"title\":\"Protocols Guide\",\"author_id\":1,\"published_at\":null,\"tags\":[\"elixir\",\"tutorial\"]}"
Mix structs, maps, and dates in one payload and every piece still finds its own implementation:
iex> complex_data = %{
...> "user" => %User{id: 1, name: "Alice", email: "[email protected]", role: "admin"},
...> "metadata" => %{"timestamp" => ~U[2024-11-10 14:30:00Z], "version" => 1}
...> }
iex> JSONEncodable.to_json(complex_data)
"{\"metadata\":{\"timestamp\":\"2024-11-10T14:30:00Z\",\"version\":1},\"user\":{\"id\":1,\"name\":\"Alice\",\"email\":\"[email protected]\",\"role\":\"admin\"}}"
Validation, same idea
Protocols aren’t only for serialization. I use them for most behavior that cuts across types, and validation is a good example. Define the contract, then let each type own its rules.
Start with the protocol:
defprotocol Validatable do
@doc "Returns {:ok, value} if valid, {:error, errors} if invalid"
def validate(value)
end
defimpl Validatable, for: User do
def validate(%User{name: name, email: email} = user) do
[]
|> validate_name(name)
|> validate_email(email)
|> case do
[] -> {:ok, user}
errors -> {:error, Enum.reverse(errors)}
end
end
defp validate_name(errors, name) do
if valid_name?(name), do: errors, else: [{:name, "Name must be 2-50 characters"} | errors]
end
defp validate_email(errors, email) do
if valid_email?(email), do: errors, else: [{:email, "Email must be valid"} | errors]
end
defp valid_name?(name) when is_binary(name) do
String.length(name) >= 2 and String.length(name) <= 50
end
defp valid_name?(_), do: false
defp valid_email?(email) when is_binary(email) do
String.contains?(email, "@") and String.length(email) > 5
end
defp valid_email?(_), do: false
end
defimpl Validatable, for: Article do
def validate(%Article{title: title, content: content} = article) do
[]
|> validate_title(title)
|> validate_content(content)
|> case do
[] -> {:ok, article}
errors -> {:error, Enum.reverse(errors)}
end
end
defp validate_title(errors, title) do
if valid_title?(title), do: errors, else: [{:title, "Title must be 5-200 characters"} | errors]
end
defp validate_content(errors, content) do
if valid_content?(content), do: errors, else: [{:content, "Content must be at least 50 characters"} | errors]
end
defp valid_title?(title) when is_binary(title) do
len = String.length(title)
len >= 5 and len <= 200
end
defp valid_title?(_), do: false
defp valid_content?(content) when is_binary(content) do
String.length(content) >= 50
end
defp valid_content?(_), do: false
end
Each validate/1 reads like a checklist. Errors pile up in a list as {field, message} pairs (a list of {atom, value} pairs is what Elixir calls a keyword list), and the list is reversed at the end so they come out in the order you checked them. The conditionals stay out of your controllers.
Using the protocol:
iex> user = %User{name: "Alice", email: "[email protected]", role: "admin"}
iex> Validatable.validate(user)
{:ok, %User{id: nil, name: "Alice", email: "[email protected]", role: "admin", created_at: nil}}
iex> bad_user = %User{name: "A", email: "bad-email"}
iex> Validatable.validate(bad_user)
{:error, [name: "Name must be 2-50 characters", email: "Email must be valid"]}
# Now you can easily check specific field errors:
iex> {:error, errors} = Validatable.validate(bad_user)
iex> Keyword.get(errors, :name)
"Name must be 2-50 characters"
iex> Keyword.has_key?(errors, :email)
true
Returning a keyword list, and not a list of strings, makes life easier for every consumer:
- Field-specific access:
Keyword.get(errors, :email) - Conditional logic:
Keyword.has_key?(errors, :name) - UI wiring: map errors directly to inputs
- Analytics: count errors by field, not raw strings
- Partial retries: re-validate only what failed
For nested data, keep composing. Each validate_* helper has the same shape as the ones above. It adds errors tagged with its section, and format_errors/2 groups them at the end:
# Complex validation with nested errors
def validate_nested_data(data) do
[]
|> validate_user_info(data.user)
|> validate_preferences(data.preferences)
|> validate_billing(data.billing)
|> format_errors(data)
end
# Group errors by section
defp format_errors([], data), do: {:ok, data}
defp format_errors(errors, _data) do
grouped = Enum.group_by(errors, &elem(&1, 0), &elem(&1, 1))
{:error, grouped}
end
The result is a map like %{user: ["Email must be valid"], billing: [...]}, which is easy to show next to the right part of a form.
The standard library already does this
Elixir’s standard library uses protocols all over:
String.Charspowers string interpolation.Inspectkeeps debugging readable.Enumerablelets any collection participate inEnum.Collectableworks the other way. It letsEnum.into/2andfor ... into:build your collection.
Implement those for your own structs and the rest of the ecosystem works with your data. Enum.map/2 doesn’t need to know your struct exists.
When not to spin up a protocol
Sometimes a protocol is overkill:
- If only your own code calls it and nobody needs to extend it, plain functions are clearer.
- If you’re choosing between modules, not data types (say, a real mailer in production and a fake one in tests), you want a behaviour, not a protocol. A behaviour is a contract that a module implements. A protocol is a contract that a data type implements.
- If only one type ever uses the behavior, a protocol adds ceremony for nothing.
- If every type behaves identically, a helper module is enough.
- If the contract is going to keep changing, plain functions are easier to refactor.
Design heuristics I keep handy
- Solve a real problem. Protocols should remove friction people already feel.
- Keep the contract small. One responsibility, well documented.
- Pick clear names. Long names beat clever abbreviations.
- Help your future self. Leave docstrings that explain why the protocol exists.
Closing the loop
A protocol moves the question “how does this type behave?” out of one central function and into the type itself. New types bring their own answer, and nobody edits a giant conditional to add one.
Find the function in your codebase that knows too much. Turn it into a protocol. The rest of the system gets a lot easier to work with.
