Protocols
Need polymorphic behavior across types? Protocols enable type-based dispatch for extensible APIs without modifying existing code, providing powerful polymorphism in functional programming.
Prerequisites
- Understanding of Elixir modules and structs
- Basic knowledge of data types
- Familiarity with function dispatch
Problem
You want different types to respond to the same function differently without case statements or type checking. Traditional approaches require modifying existing code when adding new types.
Challenges:
- Implementing type-specific behavior without conditionals
- Extending functionality for new types without changing core code
- Creating flexible APIs that work across multiple types
- Avoiding tight coupling between implementations
- Maintaining type safety with custom behavior
Solution
Use protocols to define a common interface that multiple types can implement independently.
How It Works
1. Define Protocol
defprotocol Serializable do
@doc "Convert data to JSON string"
def to_json(data)
@doc "Convert data to XML string"
def to_xml(data)
endBest Practices:
- Use clear, descriptive protocol names
- Document all protocol functions
- Define one focused responsibility per protocol
- Consider return types and error cases
2. Implement for Built-in Types
defimpl Serializable, for: List do
def to_json(list) do
Jason.encode!(list)
end
def to_xml(list) do
items = Enum.map_join(list, "\n", fn item ->
" <item>#{item}</item>"
end)
"<list>\n#{items}\n</list>"
end
end
defimpl Serializable, for: Map do
def to_json(map) do
Jason.encode!(map)
end
def to_xml(map) do
entries = Enum.map_join(map, "\n", fn {k, v} ->
" <#{k}>#{v}</#{k}>"
end)
"<map>\n#{entries}\n</map>"
end
end
defimpl Serializable, for: BitString do
def to_json(string) do
Jason.encode!(string)
end
def to_xml(string) do
"<string>#{string}</string>"
end
end3. Implement for Custom Structs
defmodule User do
defstruct [:id, :name, :email]
end
defimpl Serializable, for: User do
def to_json(%User{} = user) do
Jason.encode!(%{
id: user.id,
name: user.name,
email: user.email,
type: "user"
})
end
def to_xml(%User{} = user) do
"""
<user>
<id>#{user.id}</id>
<name>#{user.name}</name>
<email>#{user.email}</email>
</user>
"""
end
end4. Protocol Usage
user = %User{id: 1, name: "Alice", email: "alice@example.com"}
Serializable.to_json(user)
Serializable.to_json([1, 2, 3])
Serializable.to_json(%{key: "value"})
Serializable.to_xml(user)Advanced Patterns
1. Protocol Consolidation
def project do
[
app: :my_app,
version: "0.1.0",
consolidate_protocols: Mix.env() != :test
]
endProtocol consolidation happens at compile time, converting dynamic dispatch to static dispatch for better performance.
2. Fallback Implementation
defprotocol Inspectable do
@fallback_to_any true
def inspect(data)
end
defimpl Inspectable, for: Any do
def inspect(data) do
"Unknown type: #{Kernel.inspect(data)}"
end
end
defimpl Inspectable, for: User do
def inspect(%User{name: name}) do
"User: #{name}"
end
end3. Protocol Composition
defprotocol Comparable do
@doc "Compare two values, returns :lt, :eq, or :gt"
def compare(a, b)
end
defprotocol Sortable do
@doc "Sort a collection"
def sort(collection)
end
defimpl Sortable, for: List do
def sort(list) do
Enum.sort(list, fn a, b ->
Comparable.compare(a, b) != :gt
end)
end
end4. Generic Protocol Functions
defprotocol Size do
@doc "Return the size of a data structure"
def size(data)
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
defimpl Size, for: Tuple do
def size(tuple), do: tuple_size(tuple)
end
defimpl Size, for: BitString do
def size(string), do: String.length(string)
endReal-World Examples
1. API Response Rendering
defprotocol Renderable do
@doc "Render data in requested format"
def render(data, format)
end
defimpl Renderable, for: User do
def render(user, :json) do
%{
id: user.id,
name: user.name,
email: user.email
}
end
def render(user, :public) do
%{
name: user.name
}
end
def render(user, :admin) do
%{
id: user.id,
name: user.name,
email: user.email,
created_at: user.inserted_at,
updated_at: user.updated_at
}
end
end2. Event Handling
defprotocol EventHandler do
@doc "Handle an event and return new state"
def handle_event(event, state)
end
defmodule UserCreatedEvent do
defstruct [:user_id, :timestamp]
end
defmodule UserDeletedEvent do
defstruct [:user_id, :timestamp]
end
defimpl EventHandler, for: UserCreatedEvent do
def handle_event(%{user_id: id} = event, state) do
Logger.info("User created: #{id}")
%{state | user_count: state.user_count + 1}
end
end
defimpl EventHandler, for: UserDeletedEvent do
def handle_event(%{user_id: id} = event, state) do
Logger.info("User deleted: #{id}")
%{state | user_count: state.user_count - 1}
end
end3. Validation Protocol
defprotocol Validator do
@doc "Validate data and return {:ok, data} or {:error, reasons}"
def validate(data)
end
defimpl Validator, for: User do
def validate(%User{} = user) do
with :ok <- validate_email(user.email),
:ok <- validate_name(user.name) do
{:ok, user}
end
end
defp validate_email(email) when is_binary(email) do
if String.contains?(email, "@") do
:ok
else
{:error, "Invalid email format"}
end
end
defp validate_name(name) when is_binary(name) and byte_size(name) > 0 do
:ok
end
defp validate_name(_), do: {:error, "Name cannot be empty"}
endCommon Pitfalls
1. Over-Engineering with Protocols
Problem:
defprotocol SingleUse do
def process(data)
end
defimpl SingleUse, for: User do
def process(user), do: user.name
endSolution:
def process_user(%User{} = user), do: user.name2. Forgetting @fallback_to_any
defprotocol Printer do
def print(data)
end
defprotocol Printer do
@fallback_to_any true
def print(data)
end
defimpl Printer, for: Any do
def print(data), do: Kernel.inspect(data)
end3. Protocol Implementation Conflicts
defimpl MyProtocol, for: User do
def my_func(user), do: :first
end
defimpl MyProtocol, for: User do
def my_func(user), do: :second # Error!
endPerformance Considerations
Protocol Dispatch Cost
Serializable.to_json(data) # ~50-100ns overhead
User.to_json(data) # No overheadWhen to Use Protocols
Good Use Cases:
- Library APIs with extensible behavior
- Plugin systems
- Type-based routing
- Polymorphic collections
Avoid For:
- Single implementation
- Performance-critical hot paths (unless consolidated)
- Simple conditional logic
Built-in Protocols
Elixir includes several built-in protocols:
Enum.map([1, 2, 3], &(&1 * 2))
Enum.map(%{a: 1, b: 2}, fn {k, v} -> {k, v * 2} end)
to_string(123)
to_string(:atom)
inspect(%User{name: "Alice"})
Enum.into([a: 1], %{})Testing Protocols
defmodule SerializableTest do
use ExUnit.Case
defmodule TestStruct do
defstruct [:value]
end
defimpl Serializable, for: TestStruct do
def to_json(%TestStruct{value: value}) do
Jason.encode!(%{value: value})
end
def to_xml(%TestStruct{value: value}) do
"<test><value>#{value}</value></test>"
end
end
test "serializes struct to JSON" do
struct = %TestStruct{value: 42}
assert Serializable.to_json(struct) == "{\"value\":42}"
end
test "serializes struct to XML" do
struct = %TestStruct{value: 42}
assert Serializable.to_xml(struct) == "<test><value>42</value></test>"
end
test "works with built-in types" do
assert Serializable.to_json([1, 2]) == "[1,2]"
assert Serializable.to_json(%{a: 1}) == "{\"a\":1}"
end
endProtocols vs. Behaviors
Protocols:
- Type-based dispatch (polymorphism on data)
- Implemented outside the module
- Can extend existing types
- Dynamic dispatch (unless consolidated)
Behaviors:
- Module-based contracts
- Implemented inside the module (
useor@behaviour) - Compile-time checking
- Static dispatch
defprotocol Serializable do
def to_json(data)
end
defmodule MyBehaviour do
@callback handle(term()) :: term()
end
defmodule MyImpl do
@behaviour MyBehaviour
def handle(data), do: data
endRelated Resources
Last updated