Why not keep equality inside the module where it belongs?

Let’s start with a User:

defmodule User do
  defstruct [:id, :name]
end

And create two users with the same ID but different names:

user1 = %User{id: 42, name: "Robert"}
user2 = %User{id: 42, name: "Rob"}

The fields don’t all match, so Elixir’s built-in equality says they’re not equal:

user1 == user2  # false

But in our domain, these users are equal. The same ID means the same person. That’s domain equality, and that’s the problem we need to solve.

Encapsulation

One strategy is encapsulation, where we group data with its behavior.

Object-Oriented Programming

C# uses encapsulation. By default, == checks whether two objects occupy the same memory, and Equals inherits that behavior unless overridden. To implement domain-specific rules, we override the Equals method.

public class User
{
    public int Id { get; }
    public string Name { get; }

    public User(int id, string name)
    {
        Id = id;
        Name = name;
    }

    public override bool Equals(object obj)
    {
        if (obj is User other)
        {
            return this.Id == other.Id;
        }
        return false;
    }
}

Let’s create a couple of users:

var user1 = new User(42, "Robert");
var user2 = new User(42, "Rob");

These users do not share the same memory location, so == returns false:

user1 == user2;  // false

But user1 knows it is equal to user2:

user1.Equals(user2);  // true

Here, the equality behavior is encapsulated with the data. The result is each object knows how to compare itself.

Elixir

Elixir can also implement encapsulation:

defmodule User do
  defstruct [:id, :name]

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

Now:

User.eq?(user1, user2)  # true

The mental model of encapsulation is to group data with its behavior. C# groups by object and Elixir groups by modules.

Contracts

To manage contracts, C# has interfaces, Elixir has behaviours.

With Elixir, we first need a module to define the contract:

defmodule Eq do
  @callback eq?(term, term) :: boolean
end

Next, we use the @behaviour macro to enforce the contract:

defmodule User do
  @behaviour Eq
  defstruct [:id, :name]

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

With @behaviour Eq, Elixir will emit a warning if the User module does not implement the eq/2 callback.

Encapsulation manages complexity by grouping data and behavior within a clear boundary. But what if we don’t want this coupling?

Open and Closed

In functional programming, it is typical for a function to be determined entirely by its arguments. All the information needed to compute the result is passed in explicitly.

Let’s start with an equals?/3 function, which takes two values and a module:

equals? = fn (a, b, module) -> module.eq?(a, b) end

Here, the module is handling the behavior. If we use the User module we get equality by ID:

equals?.(user1, user2, User)  # true

For different behavior, we need a different module:

defmodule UserEqName do
  @behaviour Eq
  def eq?(%User{name: name1}, %User{name: name2}), do: name1 == name2
  def eq?(_, _), do: false
end

With this:

equals?.(user1, user2, UserEqName)  # false

Although Robert and Rob share the same ID, they do not have equal names.

This mental model is the open/closed principle. Logic is closed to modification but it is open to extension through injecting behaviors. If you’re coming from OOP, this is the Strategy Pattern.

If you’ve worked with functional programming, this might seem perfectly normal. But from an OOP perspective, it can feel like unnecessary indirection with no clear benefit. Why would you want to decouple data and behavior?

Is this polymorphic?

Passing a different behavior to the equal?/2 function will produce results according to that behavior.

  • Is it decoupled? Yes.
  • Is it flexible? Yes.
  • Is it polymorphic? No.

Ad hoc polymorphism is when the function dispatches differently depending on the type of input. Our solutions so far rely on the caller dictating the behavior—the function makes no choices on its own.

Polymorphism

If you come from a functional programming background, such as Haskell, you might ask, “Why are we not just extending the polymorphic == to include our domain-specific rules?”

That’s a different mental model. Here, behavior isn’t coupled to the data, but to equality itself.

In Elixir, operators are fixed, so there is no way to extend == directly.

However, we can achieve the same effect with a protocol:

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

We use Any to define the fallback case to ==:

defimpl Eq, for: Any do
  def eq?(a, b), do: a == b
end

Now, we can extend eq?/2 to include our User:

defimpl Eq, for: User do
  def eq?(%User{id: v1}, %User{id: v2}), do: Eq.eq?(v1, v2)
  def eq?(_, _), do: false
end

Here, Eq is domain-aware. For User, it picks out the ID and passes it along. In this case, it calls the Any implementation, which uses == to compare the IDs.

Eq.eq?(user1, user2)  # true

Notice that primitives work as expected:

Eq.eq?(1, 1)  # true
Eq.eq?(1, 2)  # false

While Elixir doesn’t support operator overloading, we’ve achieved the same effect. In fact, we can stop using == altogether and just use the extended Eq.eq?/2.

Let’s compare birthdays. First, we need to create a Birthday struct:

defmodule Birthday do
  defstruct [:born_on]
end

Then we need a few birthdays:

a = %Birthday{born_on: ~U[2000-08-25 12:00:00Z]}
b = %Birthday{born_on: ~U[1985-08-25 08:30:00Z]}
c = %Birthday{born_on: ~U[2000-01-01 00:00:00Z]}

By default, Eq falls back to ==:

Eq.eq?(a, b)  # false — different struct values
Eq.eq?(a, c)  # false — different struct values

But we can teach Elixir what a Birthday means:

defimpl Funx.Eq, for: Birthday do
  def eq?(%Birthday{born_on: %DateTime{month: m1, day: d1}},
           %Birthday{born_on: %DateTime{month: m2, day: d2}}),
    do: m1 == m2 and d1 == d2

  def eq?(_, _), do: false
end

Equal birthdays mean the same day and month.

Eq.eq?(a, b)  # true
Eq.eq?(a, c)  # false

Now, our Eq.eq/2 not only knows how to compare users, but birthdays as well. All without the caller having to inject the correct behavior.

Let’s add birthdays to our User:

defmodule User do
  defstruct [:birthday, :id, :name]
end

And define a few users:

user1 = %User{
  id: 1,
  name: "Robert",
  birthday: %Birthday{born_on: ~U[2000-08-25 12:00:00Z]}
}

user2 = %User{
  id: 2,
  name: "John",
  birthday: %Birthday{born_on: ~U[1990-08-25 08:00:00Z]}
}

user3 = %User{
  id: 3,
  name: "Alice",
  birthday: %Birthday{born_on: ~U[2000-01-01 00:00:00Z]}
}

Now we can compare users by their birthdays:

Eq.eq?(user1.birthday, user2.birthday)  # true
Eq.eq?(user1.birthday, user3.birthday)  # false

Solve the concept of Birthday once, and that meaning propagates wherever equality is used.

That’s too much magic!

When developers say “magic,” we don’t mean it as a compliment. Abstractions trade ease of use for immediate understanding. When that trade doesn’t feel worth it, we call it “magic.”

With polymorphism, we call a generic function like eq?/2—and somehow, the right behavior runs. Is that magic? Is the tradeoff worth it?

Back when we used encapsulation, equality was closed. We made a decision, and that decision was enforced every time we compared.

In functional programming, Eq is just the starting point. We can lift it, compose it, derive it, combine it, project it, and apply it. From the FP perspective, what we gain in composability more than justifies the trade.

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

Advanced Functional Programming with Elixir book cover