When are two things the same?

Run in Livebook

Ad hoc polymorphism is when the same function name works on different types, but does different things for each type. Like how + can add numbers or concatenate strings. The behavior depends on what types we give it.

But there are many ways to solve this problem. Some give the system control over which behavior to use (polymorphism), others give the caller control (behavior injection), and some combine both approaches.

Let’s explore seven different techniques through a simple scenario: comparing items in a media library. We want to check if two books are the same, or if two DVDs are the same, but books and DVDs have different equality rules. Each approach involves different trade-offs between simplicity, flexibility, and control.

Duck typing

The most straightforward approach: peek inside the data at runtime to figure out what type we’re dealing with. This is old-school duck typing - if it walks like a duck and quacks like a duck, treat it like a duck.

defmodule RuntimeEq do
  def eq?(a, b) do
    cond do
      Map.has_key?(a, :author) and Map.has_key?(b, :author) ->
        a.title == b.title and a.author == b.author

      Map.has_key?(a, :director) and Map.has_key?(b, :director) ->
        a.title == b.title and a.director == b.director

      true ->
        false
    end
  end
end

Let’s test this with some data to see how it works:

# Set up our test data
hobbit_book1 = %{title: "The Hobbit", author: "Tolkien"}
hobbit_book2 = %{title: "The Hobbit", author: "Tolkien"}
lotr_book = %{title: "The Lord of the Rings", author: "Tolkien"}
hobbit_dvd1 = %{title: "The Hobbit", director: "Jackson"}
hobbit_dvd2 = %{title: "The Hobbit", director: "Jackson"}
blade_runner_dvd = %{title: "Blade Runner", director: "Scott"}

RuntimeEq.eq?(hobbit_book1, hobbit_book2)  # true
RuntimeEq.eq?(hobbit_book1, lotr_book)     # false
RuntimeEq.eq?(hobbit_dvd1, hobbit_dvd2)    # true
RuntimeEq.eq?(hobbit_dvd1, blade_runner_dvd) # false

Explicit naming

Duck typing works, but peeking into data structures feels brittle. What if we’re more explicit? Instead of one eq? function that guesses, let’s create specific functions for each type.

defmodule Utils do
  def eq_book?(a, b) do
    a.title == b.title and a.author == b.author
  end

  def eq_dvd?(a, b) do
    a.title == b.title and a.director == b.director
  end
end
Utils.eq_book?(hobbit_book1, hobbit_book2)  # true
Utils.eq_book?(hobbit_book1, lotr_book)     # false
Utils.eq_dvd?(hobbit_dvd1, hobbit_dvd2)     # true
Utils.eq_dvd?(hobbit_dvd1, blade_runner_dvd) # false

Simple and explicit, but not really polymorphic. The caller has to know which function to use.

Module encapsulation

We can organize this differently by grouping data and behavior together in modules, similar to OOP. Here, each type handles its own operations - Book handles book equality, DVD handles DVD equality.

defmodule Book do
  defstruct [:id, :title, :author]

  def eq?(%Book{id: id1}, %Book{id: id2}), do: id1 == id2
  def eq?(_, _), do: false
end
defmodule DVD do
  defstruct [:id, :title, :director]

  def eq?(%DVD{id: id1}, %DVD{id: id2}), do: id1 == id2
  def eq?(_, _), do: false
end

Now let’s create some instances and test our encapsulated approach:

# Create struct instances
hobbit_book1 = %Book{id: 1, title: "The Hobbit", author: "Tolkien"}
hobbit_book2 = %Book{id: 1, title: "The Hobbit", author: "Tolkien"}
lotr_book = %Book{id: 2, title: "The Lord of the Rings", author: "Tolkien"}
hobbit_dvd1 = %DVD{id: 3, title: "The Hobbit", director: "Jackson"}
hobbit_dvd2 = %DVD{id: 3, title: "The Hobbit", director: "Jackson"}
blade_runner_dvd = %DVD{id: 4, title: "Blade Runner", director: "Scott"}

Book.eq?(hobbit_book1, hobbit_book2)  # true
Book.eq?(hobbit_book1, lotr_book)     # false
DVD.eq?(hobbit_dvd1, hobbit_dvd2)     # true
DVD.eq?(hobbit_dvd1, blade_runner_dvd) # false

We have reorganized our logic to feel more like OOP, but it’s still up to the caller to choose the right one.

Pattern dispatch

We had polymorphism with duck typing, but it required runtime inspection. With Elixir we can use pattern matching. Here, we define multiple clauses and let Elixir choose the right one at runtime based on the input types.

defmodule Eq.Pattern do
  def eq?(%Book{id: id1}, %Book{id: id2}), do: id1 == id2
  def eq?(%DVD{id: id1}, %DVD{id: id2}), do: id1 == id2
  def eq?(_, _), do: false
end

Let’s see this in practice:

Eq.Pattern.eq?(hobbit_book1, hobbit_book2)  # true
Eq.Pattern.eq?(hobbit_book1, lotr_book)     # false
Eq.Pattern.eq?(hobbit_dvd1, hobbit_dvd2)    # true
Eq.Pattern.eq?(hobbit_dvd1, blade_runner_dvd) # false
Eq.Pattern.eq?(hobbit_book1, hobbit_dvd1)   # false

Like duck typing, this gives us ad-hoc polymorphism: a single function name with multiple behaviors based on pattern-matched types.

Protocol dispatch

Elixir has protocols that let us define the interface separately from the implementations. Protocols are inspired by Haskell’s type classes.

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

Next we implement the protocol for each type:

defimpl Eq, for: Book do
  def eq?(%Book{id: id1}, %Book{id: id2}), do: id1 == id2
  def eq?(_, _), do: false
end

defimpl Eq, for: DVD do
  def eq?(%DVD{id: id1}, %DVD{id: id2}), do: id1 == id2
  def eq?(_, _), do: false
end

Now run it:

Eq.eq?(hobbit_book1, hobbit_book2)  # true
Eq.eq?(hobbit_book1, lotr_book)     # false
Eq.eq?(hobbit_dvd1, hobbit_dvd2)    # true
Eq.eq?(hobbit_dvd1, blade_runner_dvd) # false

Elixir protocols give us polymorphism, but they have a specific limitation: once we implement a protocol for a type, that’s the only implementation available. We can’t compose different behaviors without recompiling. Haskell’s type class system also has coherence rules (one instance per type per class), but it supports explicit dictionary passing when you need different behavior for the same type.

Behavior injection

We can implement Haskell’s explicit dictionary passing strategy by passing a Map with functions.

defmodule Eq.Injection do
  def eq?(a, b, eq) do
    eq.eq?.(a, b)
  end
end

Here, we create maps with our custom book and DVD equality functions:

author_eq = %{eq?: fn a, b -> a.author == b.author end}
director_eq = %{eq?: fn a, b -> a.director == b.director end}

Eq.Injection.eq?(hobbit_book1, hobbit_book2, author_eq)  # true
Eq.Injection.eq?(hobbit_dvd1, hobbit_dvd2, director_eq)     # true

This puts control back in the caller’s hands, but again they have to manage everything.

Can we follow Haskell’s lead and use default Eq with the ability to pass a dictionary?

Polymorphism with escape hatch

With Haskell we have automatic dispatch with the option to pass dictionaries explicitly when needed. We can build this in Elixir:

defmodule Eq.Utils do
  def eq?(a, b, eq \\ Eq) do
    eq = to_eq_map(eq)
    eq.eq?.(a, b)
  end

  def to_eq_map(%{} = eq_map), do: eq_map

  def to_eq_map(module) when is_atom(module) do
    %{eq?: &module.eq?/2}
  end
end

Let’s test this approach to see both automatic dispatch and custom behavior injection:

# Use like a normal protocol
Eq.Utils.eq?(hobbit_book1, hobbit_book2)  # true
Eq.Utils.eq?(hobbit_book1, lotr_book)     # false
Eq.Utils.eq?(hobbit_dvd1, hobbit_dvd2)    # true
Eq.Utils.eq?(hobbit_dvd1, blade_runner_dvd) # false
Eq.Utils.eq?(hobbit_book1, hobbit_dvd1)   # false

# Or pass custom equality
case_insensitive_eq = %{eq?: fn a, b -> String.downcase(a) == String.downcase(b) end}
Eq.Utils.eq?("Hello", "HELLO", case_insensitive_eq)  # true

This gives us the best of both worlds: default protocol dispatch for typical cases, and the ability to inject alternate behavior when needed. We can use Eq.Utils.eq?/3 just like Eq.eq?/2, but we can also override it without rewriting the function.

So if all the previous approaches already worked, why go this far? Is it just to copy Haskell?

Not quite. The real win is composability.

Most of the approaches we’ve seen (duck typing, pattern matching, protocols) embed the rules into the function itself. Once those rules are written, they’re hard to change. You’re stuck with a single definition of what it means for two things to be equal.

But when equality becomes a parameter (something you pass in) you unlock a new kind of flexibility. You can build strategies from smaller parts: wrap them, transform them with contramap, combine them with concat_all. Case-insensitive equality. Fuzzy matching. Domain-specific logic. All interchangeable, all reusable.

Functions like unique, difference, or intersect no longer bake in a definition: they accept one. That turns equality from a fixed rule into a composable building block.

You’re not just injecting behavior. You’re building a system of parts that can be mixed, matched, and reused across your entire domain.

Conclusion

The choice comes down to control: Do we want the system to automatically dispatch based on types (polymorphism), or do we need the flexibility to inject custom behavior? Elixir’s combination of protocols and first-class functions makes it easy to mix both. Most real-world Elixir projects benefit from protocols for defaults, with behavior injection as a fallback for special cases.

Resources

If you’re interested in exploring more functional programming techniques:

Advanced Functional Programming with Elixir

Dive deeper into functional programming patterns and advanced Elixir techniques. Learn how to build robust, maintainable applications using functional programming principles.

Funx - Functional Programming for Elixir

A library of functional programming abstractions for Elixir, including monads, monoids, Eq, Ord, and more. Built as an ecosystem where learning is the priority from the start.