This post walks through the basics of protocols in Elixir. It’s the information I wish I knew five years ago.

Protocols are all about polymorphism, which means writing code that behaves differently depending on its input.

Object-Oriented Interfaces

In object-oriented programming, this is typically done through interfaces:

public interface ISpeak {
    string Speak();
}

public class Duck : ISpeak {
    public string Speak() => "Quack";
}

public class Cat : ISpeak {
    public string Speak() => "Meow";
}

public static void Announce(ISpeak animal) {
    Console.WriteLine(animal.Speak());
}

Duck and Cat implement the same interface, so any function that expects ISpeak can operate on either a Duck or Cat.

Elixir Protocols

Elixir handles polymorphism through protocols.

First we define our Cat and Duck structs:

defmodule Cat do
  defstruct [:name, :microchip]
end

defmodule Duck do
  defstruct [:name, :microchip]
end

Even when the fields are the same, Elixir knows they are different types.

Now let’s define a protocol:

defprotocol Speak do
  def speak(term)
end

And implement it for our Duck and Cat:

defimpl Speak, for: Duck do
  def speak(_), do: "Quack"
end

defimpl Speak, for: Cat do
  def speak(_), do: "Meow"
end

The announcer module can use our protocol to change behavior based on input:

defmodule Announcer do
  def announce(animal) do
    IO.puts(Speak.speak(animal))
  end
end

There’s no inheritance here. Protocols dispatch based on the runtime type and call the matching implementation.

If no implementation exists, it crashes. But Elixir lets us define a default:

defprotocol Speak do
  @fallback_to_any true
  def speak(term)
end

defimpl Speak, for: Any do
  def speak(_), do: "(silence)"
end

This gives us a safe fallback when no specific implementation is found. Not all domains have a meaningful default, but when they do, we can use Any.

Now our animals speak:

whiskers = %Cat{name: "Whiskers", microchip: "123-abc"}
quacky = %Duck{name: "Quacky", microchip: "999-xyz"}

Announcer.announce(whiskers)  # prints "Meow"
Announcer.announce(quacky)    # prints "Quack"

Speak works well for animals, but functional programming emphasizes more general patterns that apply across many domains.

Protocols in Functional Programming

Let’s look at the general concept of equality:

defprotocol Eq do
  def eq?(a, b)
end

We’ve got a cat named “Whiskers.” How do we know it’s the same cat we saw last year?

We can use Eq to compare microchips:

defimpl Eq, for: Cat do
  def eq?(%Cat{microchip: a}, %Cat{microchip: b}), do: a == b
end

Notice that the first Cat pattern match isn’t necessary, it’s already handled by the implementation. This version expresses the same logic:

defimpl Eq, for: Cat do
  def eq?(%{microchip: a}, %Cat{microchip: b}), do: a == b
end

Now, with our two cats named “Whiskers”:

previous = %Cat{name: "Whiskers", microchip: "abc"}
current  = %Cat{name: "Whiskers", microchip: "xyz"}

Eq.eq?(previous, current)  # false

Even though the names match, the microchips don’t. So we know they’re not the same cat.

Protocol Limitations

Protocols in Elixir have some limitations.

Dispatches on the First Argument

Protocols dispatch only on the first argument. In a two-argument function like equality, only the first argument’s type determines which implementation is used.

defprotocol Eq do
  def eq?(a, b)
end

Calling Eq.eq?(%Cat{}, any) dispatches to the Cat implementation because protocol dispatch depends on the first argument.

In our Cat implementation, this will crash:

Eq.eq?(%Cat{}, %Duck{})
# => (FunctionClauseError)

It dispatched to Cat, but Duck didn’t match. Note that it will not fallback to Any. If the logic cannot be resolved within the Cat implementation, it will crash.

Most of the time, equality is homogeneous—comparing two of the same type. If the domain needs heterogeneous equality, we can relax the match:

defimpl Eq, for: Cat do
  def eq?(%{microchip: a}, %{microchip: b}), do: a == b
end

This allows comparison of Cat with any Struct with a microchip key, but first argument still must be a Cat.

Also, if the second argument does not have the microchip key, this will crash.

No Optional Functions

In Haskell, Eq includes both (==) and (/=), but (/=) is optional. If it’s not defined, Haskell derives not-equal from equal: x /= y = not (x == y).

Elixir protocols don’t support optional functions or default implementations. If we want not_eq?/2, every implementation must define it. However, this is just the protocol, the implementations can choose to derive not_eq?/2 from eq?/2.

This topic is explored in more depth in my book, Advanced Functional Programming with Elixir, available now in beta from The Pragmatic Bookshelf.

description