Home Posts Post Search Tag Search

Advanced Functional Elixir - 02 - Domain-Specific Equality with Protocols
Published on: 2026-03-05 Tags: elixir, Blog, Side Project, Advanced Functional Programming, FunPark

2. Implement Domain-Specific Equality with Protocols

Identity asks if two things are the same. Equality asks if they share the similar characteristics


With this we can talk about the identity of the care which can be found by the VIN. Or we can talk about whether they are equal, for an insurance they are the same car because (assuming same model and year) they share the same parts, they share the same and the same risks in an accident.


We will now try and define the Eq protocol that will take structs and try and find out if they are “equal” or not. In order to do this we would like and group to be equal if:

• Reflexivity: a = a.
• Symmetry: If a = b, then b = a.
• Transitivity: If a = b and b = c, then a = c.

Polymorphic Equality

defprotocol FunPark.Eq do
  @fallback_to_any true

  def eq?(a, b)

  def not_eq?(a, b)
end

This might be a bit to look at but there are a couple things that might help to talk about here. A protocol is there to deal with data and can be implemented for different data types. Right not if you want to use the FunPark.Eq protocol you must use either eq?/2 or not_eq?/2 it will run for any data type. We will get into reasons where this might not be perfect later. Keep in mind that we have not setup what they are going to compare yet.


Also this is done at runtime so if we don’t have an implementation for the compare values there will be an error. We set the @fallback_to_any so that there will always be a return value.


Now let’s implement the first version of the eq

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

Run It

First using the given == operator we can see some comparisons

iex(1)> 1 == 1
true
iex(2)> 1 == 2
false
iex(3)> FunPark.Eq.eq?(1, 1)
true
iex(4)> FunPark.Eq.eq?(1, 2)
false

Implement Equality for FunPark Contexts

Okay so now that we have the idea of an equality we need a way to update a Patron. In this way we need to be able to set values for a patron. We will do this by adding in change/2 function that will use the struct/2 Elixir function.

  def change(%__MODULE__{} = patron, attrs) when is_map(attrs) do
    attrs = Map.delete(attrs, :id)

    struct(patron, attrs)
  end

One thing to keep in mind here is that we want to remove the ID from the attrs so that we don’t change the id of the Patron that we are updating. So we take a Patron and then update and return the same type the change/2 is closed.


In set theory, a set is closed under an operation if applying that operation to elements
of the set always produces another element of the same set.

In functional programming, we use the term similarly: a function satisfies closure if
it returns the same type it receives.

Run It

iex(5)> alice_a = FunPark.Patron.make("Alice", 15, 150)
%FunPark.Patron{
  id: 194,
  name: "Alice",
  age: 15,
  height: 150,
  ticket_tier: :basic,
  fast_passes: [],
  reward_points: 0,
  likes: [],
  dislikes: []
}
iex(6)> alice_b = FunPark.Patron.change(alice_a, %{ticket_tier: :premium})
%FunPark.Patron{
  id: 194,
  name: "Alice",
  age: 15,
  height: 150,
  ticket_tier: :premium,
  fast_passes: [],
  reward_points: 0,
  likes: [],
  dislikes: []
}
iex(7)> alice_a == alice_b
false

Let’s go over this. First we created a patron alice_a that has the values that we want. We then created a new patron alice_b that has a different ticket_tier. We then checked to see if the patrons are the same and we got a false.


So the change worked as intended but the equality didn’t as changing the ticket_tier should still say that the patrons are the same if there ID is the same.


Implement the Eq Protocol

Now we need to leverage the Protocol that we set earlier in the other code.

defimpl FunPark.Eq, for: FunPark.Patron do
  alias FunPark.Eq
  alias FunPark.Patron
  def eq?(%Patron{id: v1}, %Patron{id: v2}), do: Eq.eq?(v1, v2)
  def not_eq?(%Patron{id: v1}, %Patron{id: v2}), do: Eq.not_eq?(v1, v2)
end

This is saying that when we use the FunPark.Patron data type that we are going to redefine the eq? and not_eq? in this way. We pull the id from the 2 data types and compare those. We need to alias the different modules to be sure that they are able to be used.


So to wrap it all up we are taking the protocol that we have made in the other file and then saying this is how we should use it for the Patron data type. We have pattern matching to be sure that although we are don’t need it for the first argument the second will still be checked to see if it is a Patron. Once it is called we then pull the ID out of the Patrons and then send it to the Eq.eq? but with values that can be compared with Elixirs built-in compare.


Run It

iex(8)> FunPark.Eq.eq?(alice_a, alice_b)
true

This was continuing from the iex session from before. We see that they are the same with the equality.

Ride Eq

Let’s do the same thing for the Ride

  def change(%__MODULE__{} = ride, attrs) when is_map(attrs) do
    attrs = Map.delete(attrs, :id)

    struct(ride, attrs)
  end

Then the implementation for the Ride eq

defimpl FunPark.Eq, for: FunPark.Ride do
  alias FunPark.Eq
  alias FunPark.Ride
  def eq?(%Ride{id: v1}, %Ride{id: v2}), do: Eq.eq?(v1, v2)
  def not_eq?(%Ride{id: v1}, %Ride{id: v2}), do: Eq.not_eq?(v1, v2)
end

Now let’s do the same thing to check if it works the same

iex(9)> ride_a = FunPark.Ride.make("Dark Mansion", min_age: 14, tags: [:dark])
%FunPark.Ride{
  id: 258,
  name: "Dark Mansion",
  min_age: 14,
  min_height: 0,
  wait_time: 0,
  online: true,
  tags: [:dark]
}
iex(10)> ride_b = FunPark.Ride.change(ride_a, %{wait_time: 20})
%FunPark.Ride{
  id: 258,
  name: "Dark Mansion",
  min_age: 14,
  min_height: 0,
  wait_time: 20,
  online: true,
  tags: [:dark]
}
iex(11)> ride_a == ride_b
false
iex(12)> FunPark.Eq.eq?(ride_a, ride_b)
true

FastPass Eq

Now let’s do the same thing for FastPass

  def change(%__MODULE__{} = fast_pass, attrs) when is_map(attrs) do
    attrs = Map.delete(attrs, :id)

    struct(fast_pass, attrs)
  end

We then will also need to implement the same protocol for FastPass as with the Patron and Ride

defimpl FunPark.Eq, for: FunPark.FastPass do
  alias FunPark.Eq
  alias FunPark.FastPass
  def eq?(%FastPass{id: v1}, %FastPass{id: v2}), do: Eq.eq?(v1, v2)
  def not_eq?(%FastPass{id: v1}, %FastPass{id: v2}), do: Eq.not_eq?(v1, v2)
end

So in this implementation we are asking if the id for the pass is the same.

Run It

iex(13)> tea_cup = FunPark.Ride.make("Tea Cup")
%FunPark.Ride{
  id: 322,
  name: "Tea Cup",
  min_age: 0,
  min_height: 0,
  wait_time: 0,
  online: true,
  tags: []
}
iex(14)> datetime = DateTime.new!(~D[2025-06-01], ~T[13:00:00])
~U[2025-06-01 13:00:00Z]
iex(15)> pass_a = FunPark.FastPass.make(tea_cup, datetime)
%FunPark.FastPass{
  id: 514,
  ride: %FunPark.Ride{
    id: 322,
    name: "Tea Cup",
    min_age: 0,
    min_height: 0,
    wait_time: 0,
    online: true,
    tags: []
  },
  time: ~U[2025-06-01 13:00:00Z]
}
iex(16)> haunted_mansion = FunPark.Ride.make("Haunted Mansion")
%FunPark.Ride{
  id: 578,
  name: "Haunted Mansion",
  min_age: 0,
  min_height: 0,
  wait_time: 0,
  online: true,
  tags: []
}
iex(17)> pass_b = FunPark.FastPass.change(pass_a, %{ride: haunted_mansion})
%FunPark.FastPass{
  id: 514,
  ride: %FunPark.Ride{
    id: 578,
    name: "Haunted Mansion",
    min_age: 0,
    min_height: 0,
    wait_time: 0,
    online: true,
    tags: []
  },
  time: ~U[2025-06-01 13:00:00Z]
}
iex(18)> pass_a == pass_b
false
iex(19)> FunPark.Eq.eq?(pass_a, pass_b)
true

Okay so we created a ride then a fast pass for that ride. We then changed the fast pass ride to an other. Even though the rides are different we have the same ID so the fast pass are the same.

Equality Is Contextual

Our expert pointed out that a person can’t be at the same place at the same time and thus we should target the time not the ID.

defimpl FunPark.Eq, for: FunPark.FastPass do
  alias FunPark.Eq
  alias FunPark.FastPass
  def eq?(%FastPass{time: v1}, %FastPass{time: v2}), do: Eq.eq?(v1, v2)
  def not_eq?(%FastPass{time: v1}, %FastPass{time: v2}),
    do: Eq.not_eq?(v1, v2)
end

Okay but maybe we don’t want to reimplement the eq for a fast pass. We want an alt that checks the time. So we won’t use the the above and find a workaround.

Transform Inputs Before Matching

Okay so we need to go over the contravariant functor which will transform the input before its processed. Since Elixir doesn’t have a built-in contramap we can define it ourselves.

defmodule FunPark.Eq.Utils do
  alias FunPark.Eq

  def contramap(f, eq \\ Eq) do
    %{
      eq?: fn a, b -> eq.eq?.(f.(a), f.(b)) end,
      not_eq?: fn a, b -> eq.not_eq?.(f.(a), f.(b)) end
    }
  end

Okay so we have a way to pass a function into the contramap/1 let’s make the function that we want to use and then add a way to use the above.

  def get_time(%__MODULE__{time: time}), do: time

  def eq_time do
    Eq.Utils.contramap(&get_time/1)
  end

So we implemented a way to pull the time out of a struct. In this case a FastPass as that is where this function is defined. We then use that within the eq_time to be sure that we call the right Eq function.


Run It

iex(20)> mansion = FunPark.Ride.make("Dark Mansion", min_age: 14, tags: [:dark])
%FunPark.Ride{
  id: 642,
  name: "Dark Mansion",
  min_age: 14,
  min_height: 0,
  wait_time: 0,
  online: true,
  tags: [:dark]
}
iex(21)> tea_cup = FunPark.Ride.make("Tea Cup")
%FunPark.Ride{
  id: 706,
  name: "Tea Cup",
  min_age: 0,
  min_height: 0,
  wait_time: 0,
  online: true,
  tags: []
}
iex(22)> datetime = DateTime.new!(~D[2025-06-01], ~T[13:00:00])
~U[2025-06-01 13:00:00Z]
iex(23)> fast_pass_a = FunPark.FastPass.make(mansion, datetime)
%FunPark.FastPass{
  id: 898,
  ride: %FunPark.Ride{
    id: 642,
    name: "Dark Mansion",
    min_age: 14,
    min_height: 0,
    wait_time: 0,
    online: true,
    tags: [:dark]
  },
  time: ~U[2025-06-01 13:00:00Z]
}
iex(24)> fast_pass_b = FunPark.FastPass.make(tea_cup, datetime)
%FunPark.FastPass{
  id: 962,
  ride: %FunPark.Ride{
    id: 706,
    name: "Tea Cup",
    min_age: 0,
    min_height: 0,
    wait_time: 0,
    online: true,
    tags: []
  },
  time: ~U[2025-06-01 13:00:00Z]
}
iex(25)> FunPark.Eq.eq?(fast_pass_a, fast_pass_b)
false
iex(26)> FunPark.FastPass.eq_time.eq?.(fast_pass_a, fast_pass_b)
true

Okay let’s go over this. We did the same thing as before created a ride then a fast pass then changed the ride. We then use the old method to check for equality, it fails. This is because they have different IDs. We then use the new method to check and we get a true because that one will pull the time and see if they are the same.


I want you to think about this last call as follows:

Call FunPark.FastPass.eq_time/0

Get back a map

Read the :eq? key from that map

That key contains a function

Call that function with fast_pass_a and fast_pass_b

You’re Gonna Need a Bigger Boat

Okay so right now we need a way to deal with Modules (implicitly) and Maps (explicitly) that are passed.

  def to_eq_map(%{eq?: eq_fun, not_eq?: not_eq_fun} = eq_map)
      when is_function(eq_fun, 2) and is_function(not_eq_fun, 2) do
    eq_map
  end

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

Then we can change the contramap/2 as follows

  def contramap(f, eq \\ Eq) do
    eq = to_eq_map(eq)

    %{
      eq?: fn a, b -> eq.eq?.(f.(a), f.(b)) end,
      not_eq?: fn a, b -> eq.not_eq?.(f.(a), f.(b)) end
    }
  end

This allows us to be able to pass in the module or a map to get to the same place.

Simplify Equality Checks

We now need a way to make sure that we will pull the right Eq depending on whether we use the contramap/2 or by the protocol.

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


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

This will ensure that we will always be able to call the Utils.eq?/3 from anywhere and then use the standard eq?/2 or pass in a way to transform that data before the compare.


Run It

iex(27)> mansion = FunPark.Ride.make("Dark Mansion", min_age: 14, tags: [:dark])
%FunPark.Ride{
  id: 1026,
  name: "Dark Mansion",
  min_age: 14,
  min_height: 0,
  wait_time: 0,
  online: true,
  tags: [:dark]
}
iex(28)> tea_cup = FunPark.Ride.make("Tea Cup")
%FunPark.Ride{
  id: 1090,
  name: "Tea Cup",
  min_age: 0,
  min_height: 0,
  wait_time: 0,
  online: true,
  tags: []
}
iex(29)> datetime = DateTime.new!(~D[2025-06-01], ~T[13:00:00])
~U[2025-06-01 13:00:00Z]
iex(30)> fast_pass_a = FunPark.FastPass.make(mansion, datetime)
%FunPark.FastPass{
  id: 1282,
  ride: %FunPark.Ride{
    id: 1026,
    name: "Dark Mansion",
    min_age: 14,
    min_height: 0,
    wait_time: 0,
    online: true,
    tags: [:dark]
  },
  time: ~U[2025-06-01 13:00:00Z]
}
iex(31)> fast_pass_b = FunPark.FastPass.make(tea_cup, datetime)
%FunPark.FastPass{
  id: 1346,
  ride: %FunPark.Ride{
    id: 1090,
    name: "Tea Cup",
    min_age: 0,
    min_height: 0,
    wait_time: 0,
    online: true,
    tags: []
  },
  time: ~U[2025-06-01 13:00:00Z]
}
iex(32)> FunPark.Eq.Utils.eq?(fast_pass_a, fast_pass_b)
false
iex(33)> has_eq_time = FunPark.FastPass.eq_time()
%{
  eq?: #Function<0.19875996/2 in FunPark.Eq.Utils.contramap/2>,
  not_eq?: #Function<1.19875996/2 in FunPark.Eq.Utils.contramap/2>
}
iex(34)> FunPark.Eq.Utils.eq?(fast_pass_a, fast_pass_b, has_eq_time)
true

Okay let’s go over this. We did the same thing again, I won’t go over those steps again. But at the end we created an anonymous function haseq_time and then passed that into the _Utils.eq?/3 which then changed the Eq into the eq_time() which then pulled out the time instead of the ID.


Harness Equality for Collections

Operation Description
uniq/2 Remove duplicates.
union/3 Combine unique elements.
intersection/3 Find common elements.
difference/3 Exclude elements in second list.
symmetric_difference/3 Elements in either, but not both.
subset?/3 Check if all elements exist in another list.
superset?/3 Check if one list contains all elements of another.

Unique

  def uniq(list, eq \\ FunPark.Eq) when is_list(list) do
    list
    |> Enum.reduce([], fn item, acc ->
      if Enum.any?(acc, &Eq.Utils.eq?(item, &1, eq)),
        do: acc,
        else: [item | acc]
    end)
    |> :lists.reverse()
  end

Now we can test with 2 copies of Alice

iex> alice_a = FunPark.Patron.make("Alice", 15, 50)
%FunPark.Patron{
  id: 2,
  name: "Alice",
  ticket_tier: :basic,
  ...
}
iex> alice_b = FunPark.Patron.change(alice_a, %{ticket_tier: :premium})
%FunPark.Patron{
  id: 2,
  name: "Alice",
  ticket_tier: :premium,
  ...
}
iex> FunPark.List.uniq([alice_a, alice_b])
[
  %FunPark.Patron{
    id: 2,
    name: "Alice",
    ticket_tier: :basic,
    ...
  }
]

Union

  def union(list1, list2, eq \\ FunPark.Eq)
      when is_list(list1) and is_list(list2) do
    (list1 ++ list2) |> uniq(eq)
  end

Now we can test with with 3 rides and maintenance and breakdown logs.

ex> tea_cup = FunPark.Ride.make("Tea Cup")
iex> haunted_mansion = FunPark.Ride.make("Haunted Mansion")
iex> apple_cart = FunPark.Ride.make("Apple Cart")
iex> maintenance_log = [haunted_mansion, apple_cart]
iex> breakdown_log = [tea_cup, haunted_mansion]
iex> FunPark.List.union(maintenance_log, breakdown_log)
[
  %FunPark.Ride{name: "Haunted Mansion", ...},
  %FunPark.Ride{name: "Apple Cart", ...},
  %FunPark.Ride{name: "Tea Cup", ...}
]

Intersection

  def intersection(list1, list2, eq \\ FunPark.Eq)
      when is_list(list1) and is_list(list2) do
    list1
    |> Enum.filter(fn item ->
      Enum.any?(list2, &Eq.Utils.eq?(item, &1, eq))
    end)
    |> uniq(eq)
  end

We can test with long_wait times and most_fast_pass

ex> long_wait = [haunted_mansion, apple_cart]
iex> most_fast_pass = [tea_cup, haunted_mansion]
iex> FunPark.List.intersection(long_wait, most_fast_pass)
[
  %FunPark.Ride{name: "Haunted Mansion", ...}
]

Difference

  def difference(list1, list2, eq \\ FunPark.Eq)
      when is_list(list1) and is_list(list2) do
    list1
    |> Enum.reject(fn item ->
      Enum.any?(list2, &Eq.Utils.eq?(item, &1, eq))
    end)
    |> uniq(eq)
  end

Now we can all the rides that are open with all_rides and restricted_rides

ex> all_rides = [haunted_mansion, apple_cart]
iex> restricted_rides = [haunted_mansion]
iex> FunPark.List.difference(all_rides, restricted_rides)
[
  %FunPark.Ride{name: "Apple Cart", ...}
]

Symmetric Difference

  def symmetric_difference(list1, list2, eq \\ FunPark.Eq)
      when is_list(list1) and is_list(list2) do
    (difference(list1, list2, eq) ++
       difference(list2, list1, eq))
    |> uniq(eq)
  end

Now we can test against long_wait and fast_pass_usage

iex> long_wait_times = [haunted_mansion, apple_cart]
iex> fast_pass_usage = [tea_cup, haunted_mansion]
iex> FunPark.List.symmetric_difference(
long_wait_times,
fast_pass_usage
)
[
  %FunPark.Ride{name: "Apple Cart", ...},
  %FunPark.Ride{name: "Tea Cup", ...}
]

Subset

  def subset?(small, large, eq \\ FunPark.Eq)
      when is_list(small) and is_list(large) do
    Enum.all?(small, fn item ->
      Enum.any?(large, &Eq.Utils.eq?(item, &1, eq))
    end)
  end

Now we can test again fast_pass_rides and completed_rides

iex> fast_pass_rides = [tea_cup, banana_slip]
iex> rides_completed = [haunted_mansion, tea_cup, banana_slip]
iex> FunPark.List.subset?(fast_pass_rides, rides_completed)
true

Superset

  def superset?(large, small, eq \\ FunPark.Eq)
      when is_list(small) and is_list(large) do
    subset?(small, large, eq)
  end

Now we can test against fast_pass_rides and rides_completed

iex> fast_pass_rides = [haunted_mansion]
iex> rides_completed = [tea_cup, banana_slip, haunted_mansion]
iex> FunPark.List.superset?(rides_completed, fast_pass_rides)
true

What You’ve Learned

Equality isn’t just basic comparison. You need to understand what you are testing against and then normalize the data. We implemented an Eq protocol that will take the data and then compare with elixirs comparison. You could even use that to compare higher-level operations.