Elixir by Example

Protocols

Edit any cell and run it in your browser Download source

A protocol is polymorphism for data: one function name, a separate implementation per type. It's how to_string/1, inspect/1 and Enum work on so many types.

defprotocol Describe do
  @doc "Returns a short human description."
  def describe(value)
end

Implementations live with defimpl, for built-in types or your own structs, and can sit in a different module or library from the protocol.

defimpl Describe, for: Integer do
  def describe(n) when n < 0, do: "a negative integer"
  def describe(_n), do: "an integer"
end

defimpl Describe, for: BitString do
  def describe(s), do: "a string of #{String.length(s)} characters"
end

defimpl Describe, for: List do
  def describe(list), do: "a list of #{length(list)} items"
end

IO.puts(Describe.describe(42))
IO.puts(Describe.describe(-1))
IO.puts(Describe.describe("héllo"))
IO.puts(Describe.describe([1, 2, 3]))
an integer
a negative integer
a string of 5 characters
a list of 3 items

Structs get their own implementation, separate from plain maps.

defmodule Temp do
  defstruct [:celsius]
end

defimpl Describe, for: Temp do
  def describe(%{celsius: c}), do: "a temperature of #{c}°C"
end

IO.puts(Describe.describe(struct(Temp, celsius: 21)))
a temperature of 21°C

Calling a protocol for a type with no implementation raises Protocol.UndefinedError.

try do
  Describe.describe(:an_atom)
rescue
  e in Protocol.UndefinedError ->
    IO.puts(e |> Exception.message() |> String.split("\n") |> hd())
end
protocol Describe not implemented for Atom

Built-in protocols plug your types into the language. Implementing String.Chars makes to_string/1 and interpolation work; @derive picks a ready-made implementation, here one that hides a field from inspect.

defmodule Account do
  @derive {Inspect, except: [:password]}
  defstruct [:login, :password]
end

defimpl String.Chars, for: Account do
  def to_string(%{login: login}), do: "account #{login}"
end

account = struct(Account, login: "ada", password: "hunter2")
IO.puts("Logged in as #{account}")
IO.inspect(account)
Logged in as account ada
#Account<login: "ada", ...>

Pitfall: prefer a protocol over a big case/cond on types when other people need to add types later. If every type is yours and known up front, plain pattern matching in function heads is simpler.

Pitfall: @derive {Inspect, except: [...]} only hides values from inspect, such as logs and crash reports. The data is still in the struct and still serialized by JSON encoders unless you exclude it there too.