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)
end

Best 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
end

3. 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
end

4. 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
  ]
end

Protocol 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
end

3. 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
end

4. 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)
end

Real-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
end

2. 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
end

3. 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"}
end

Common 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
end

Solution:

def process_user(%User{} = user), do: user.name

2. 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)
end

3. 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!
end

Performance Considerations

Protocol Dispatch Cost

Serializable.to_json(data)  # ~50-100ns overhead

User.to_json(data)  # No overhead

When 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
end

Protocols 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 (use or @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
end

Related Resources

Last updated