“A model is a selectively simplified and consciously structured form of knowledge.” — Eric Evans

Equality quietly drives a lot of Elixir code: de-duplication, membership checks, grouping, rule matching, and validation all hinge on what it means for two things to be “the same.” Most code leaves that definition implicit, relying on structural == or scattering projections like uniq_by/2 throughout the codebase.

Funx provides Eq. By default it delegates to Elixir’s structural equality. However, we can define a default via Elixir’s protocol, or pass an explicit Eq when a different comparison is required.

Run in Livebook

Playing Card

Let’s look at Eq from the perspective of a playing card:

defmodule Card do
  defstruct [:id, :rank, :suit]

  def new(rank, suit) do
    %__MODULE__{
      id: :erlang.unique_integer([:positive]) |> Integer.to_string(),
      rank: rank,
      suit: suit
    }
  end
end

First, we need some Card data:

four_heart = Card.new("4", "H")
ten_heart = Card.new("10", "H")

four_spade = Card.new("4", "S")
ten_spade = Card.new("10", "S")

Elixir uses ==:

four_heart == four_heart # true
four_heart == four_spade # false

Funx has Eq.eq?/2, which defaults to Elixir’s ==:

alias Funx.Eq

Eq.eq?(four_heart, four_heart)

# true

Where a card is equal to itself.

And different cards are not equal:

Eq.eq?(four_heart, four_spade)

# false

But we can go further, Funx’s Eq.eq?/3 accepts an Eq instance.

It’s possible to construct an Eq instance by hand, but it’s easier using Funx’s Eq DSL:

use Funx.Eq

rank_eq =
  eq do
    on :rank
  end

Eq.eq?(four_heart, four_spade, rank_eq)

# true

Now the same card rank evaluates to true.

And different ranks evaluate to false:

Eq.eq?(four_heart, ten_heart, rank_eq)

# false

Cards can also be equal by suit:

suit_eq =
  eq do
    on :suit
  end

Eq.eq?(four_heart, ten_heart, suit_eq)

# true

Where different suits evaluate to false:

Eq.eq?(four_heart, four_spade, suit_eq)

# false

Our deck can contain duplicates. Here is another four of hearts with a different id:

four_heart_b = Card.new("4", "H") # different id

Eq.eq?(four_heart, four_heart_b)

# false

Again, by default Eq.eq?/2 uses Elixir’s structural equality, so the id matters.

But in our domain, the id is not part of card identity. We only care about suit and rank:

card_eq =
  eq do
    on :suit
    on :rank
  end

Eq.eq?(four_heart, four_heart_b, card_eq)

# true

With our card_eq Funx understands that two cards with the same rank and suit are equal, regardless of their id.

We can express other equality, such as game rules:

playable_eq =
  eq do
    any do
      on :suit
      on :rank
    end
  end

Eq.eq?(four_heart, four_spade, playable_eq)

# true

Here, two cards with matching ranks are playable.

And two cards of the same suit are also playable:

Eq.eq?(four_heart, ten_heart, playable_eq)

# true

But a card with a different suit and rank is not playable:

Eq.eq?(four_heart, ten_spade, playable_eq)

# false

When we use atoms in the DSL, Funx is using the Prism optic behind the scenes.

Let’s see this with an incomplete record:

incomplete_four = %{rank: "4"}

Eq.eq?(four_heart, incomplete_four, playable_eq)

# true

A Prism on :suit focuses to Nothing when the key is missing, but the any block passes because :rank still matches.

If our domain requires that only a Card can match another Card, we can use Prism.path/1 to explicitly include the Card struct:

alias Funx.Optics.Prism

playable_eq =
  eq do
    any do
      on Prism.path([{Card, :suit}])
      on Prism.path([{Card, :rank}])
    end
  end

Eq.eq?(four_heart, incomplete_four, playable_eq)

# false

This narrows equality so that only a Card can match a Card.

But let’s see what happens when we compare two incomplete maps:

incomplete_five = %{rank: "5"}
Eq.eq?(incomplete_four, incomplete_five, playable_eq)

# true

The result is true, which is expected. In the context of Card, these values are Nothing, and two Nothing values are equal.

If our domain requires a focus to exist, we should switch to a Lens optic:

alias Funx.Optics.Lens

playable_eq =
  eq do
    any do
      on Lens.key(:suit)
      on Lens.key(:rank)
    end
  end

Now missing keys raise, enforcing the invariant at runtime (fail fast):

Eq.eq?(four_heart, incomplete_four, playable_eq)

# ** (KeyError) key :suit not found in: %{rank: "4"}

A Lens enforces the focus; it does not care if the values are Card structs:

four_club_map = %{rank: "4", suit: "C"}

Eq.eq?(four_heart, four_club_map, playable_eq)

# true

Again, this is expected. A Lens works on a product type. If we needed to differentiate between two types (Card and Map), that’s a sum type problem, which is a job for the optic Prism.

Model the Domain

When we model a domain, we don’t want our equality rules scattered throughout the code.

Instead, we can keep them within a module:

defmodule Game.Card do
  use Funx.Eq
  use Funx.Ord
  import Funx.Macros, only: [eq_for: 2, ord_for: 2]
  alias Funx.Optics.Lens

  defstruct [:id, :rank, :suit]

  def new(rank, suit) do
    %__MODULE__{
      id: :erlang.unique_integer([:positive]) |> Integer.to_string(),
      rank: rank,
      suit: suit
    }
  end

  def suit_eq do
    eq do
      on Lens.key(:suit)
    end
  end

  def rank_eq do
    eq do
      on Lens.key(:rank)
    end
  end

  def suit_ord do
    ord do
      asc Lens.key(:suit)
    end
  end

  eq_for(
    Game.Card,
    eq do
      on Lens.key(:rank)
      on Lens.key(:suit)
    end
  )

  ord_for(
    Game.Card,
    ord do
      asc Lens.key(:suit)
      desc Lens.key(:rank)
    end
  )
end

Let’s take a closer look at eq_for/2. This is how we tell Funx what the domain’s default equality is for a Card. Funx will use this rule instead of Elixir’s structural equality.

Let’s regenerate our cards, but this time using Game.Card:

alias Game.Card

four_heart = Card.new("4", "H")
ten_heart = Card.new("10", "H")

four_spade = Card.new("4", "S")
ten_spade = Card.new("10", "S")

And we need the duplicate four of hearts (same suit and rank, different id):

four_heart_b = Card.new("4", "H")

Now that Game.Card has a protocol Eq, Funx knows the domain rule, which is to ignore id and only focus on rank and suit:

Eq.eq?(four_heart, four_heart_b)

# true

Let’s see how that helps us with list operations.

Lists

First, we need a list of cards:

cards = [four_heart, four_heart_b, four_spade, four_heart]

Elixir’s Enum.uniq/1 removes identical terms, but it doesn’t recognize that four_heart_b is semantically the same card:

Enum.uniq(cards)

# [
#   %Game.Card{id: "13859", rank: "4", suit: "H"},
#   %Game.Card{id: "13987", rank: "4", suit: "H"},
#   %Game.Card{id: "13923", rank: "4", suit: "S"}
# ]

We could use Elixir’s Enum.uniq_by/2, where we can inject a projection:

Enum.uniq_by(cards, fn %Game.Card{rank: rank, suit: suit} -> {rank, suit} end)

# [
#   %Game.Card{id: "13859", rank: "4", suit: "H"}, 
#   %Game.Card{id: "13923", rank: "4", suit: "S"}
# ]

This works, but it tends to spread the domain rule across the codebase.

With Funx, uniq already understands the domain rules for a Card:

Funx.List.uniq(cards)

# [
#   %Game.Card{id: "13859", rank: "4", suit: "H"}, 
#   %Game.Card{id: "13923", rank: "4", suit: "S"}
# ]

Elixir also has Enum.member?/2, which checks whether an equal term exists in the list:

Enum.member?([four_heart, four_spade], four_heart_b)

# false

Here, Elixir is using its default structural equality, so it does not recognize that a four of hearts exists in the list. There is no member_by? where we can inject our projection.

Funx provides Funx.List.elem?, which respects the domain’s equality definition:

Funx.List.elem?([four_heart, four_spade], four_heart_b)

# true

It also accepts custom equality logic, such as equality by suit:

Funx.List.elem?([four_heart, four_spade], ten_spade, Card.suit_eq)

Where a four of diamonds doesn’t match by suit:

four_diamond = Card.new("4", "D")
Funx.List.elem?([four_heart, four_spade], four_diamond, Card.suit_eq)

# false

But it does match by rank:

Funx.List.elem?([four_heart, four_spade], four_diamond, Card.rank_eq)

# true

Instead of injecting projection functions, Funx uses Eq.

Managing Game Rules

Let’s start with a hand of cards:

hand = [ 
  ten_heart, 
  four_heart, 
  four_heart_b,
  ten_spade,
  four_spade, 
  four_diamond
]

And we can place our eq rule in our Game aggregate:

defmodule Game do
  alias Game.Card

  def playable_card_eq do
    eq do
      any do
        Card.rank_eq()
        Card.suit_eq()
      end
    end
  end
end

In our game, a 10 of clubs was played:

played_card = Card.new("10", "C")

We can use partition/3 to split our hand into playable and non-playable cards:

Funx.List.partition(hand, played_card, Game.playable_card_eq)

# {
#   [
#     %Game.Card{id: "13891", rank: "10", suit: "H"},
#     %Game.Card{id: "13955", rank: "10", suit: "S"}
#   ],
#   [
#     %Game.Card{id: "13859", rank: "4", suit: "H"},
#     %Game.Card{id: "13987", rank: "4", suit: "H"},
#     %Game.Card{id: "13923", rank: "4", suit: "S"},
#     %Game.Card{id: "14019", rank: "4", suit: "D"}
#   ]
# }

The playable cards in our hand are the 10 of hearts or the 10 of spades.

We can also group a hand using Eq:

Funx.List.group(hand)

# [
#   [%Game.Card{id: "13891", rank: "10", suit: "H"}],
#   [
#     %Game.Card{id: "13859", rank: "4", suit: "H"}, 
#     %Game.Card{id: "14435", rank: "4", suit: "H"}
#   ],
#   [%Game.Card{id: "13955", rank: "10", suit: "S"}],
#   [%Game.Card{id: "13923", rank: "4", suit: "S"}],
#   [%Game.Card{id: "14467", rank: "4", suit: "D"}]
# ]

This groups our duplicate 4 of hearts.

Let’s group by rank instead:

Funx.List.group(hand, Card.rank_eq)

# [
#   [%Game.Card{id: "13891", rank: "10", suit: "H"}],
#   [
#     %Game.Card{id: "13859", rank: "4", suit: "H"}, 
#     %Game.Card{id: "14435", rank: "4", suit: "H"}
#   ],
#   [%Game.Card{id: "13955", rank: "10", suit: "S"}],
#   [
#     %Game.Card{id: "13923", rank: "4", suit: "S"}, 
#     %Game.Card{id: "14467", rank: "4", suit: "D"}
#   ]
# ]

Or by suit:

Funx.List.group(hand, Card.suit_eq)

# [
#   [
#     %Game.Card{id: "13891", rank: "10", suit: "H"},
#     %Game.Card{id: "13859", rank: "4", suit: "H"},
#     %Game.Card{id: "14435", rank: "4", suit: "H"}
#   ],
#   [
#     %Game.Card{id: "13955", rank: "10", suit: "S"},
#     %Game.Card{id: "13923", rank: "4", suit: "S"}
#   ],
#   [%Game.Card{id: "14467", rank: "4", suit: "D"}]
# ]

We can combine grouping with sorting using group_sort/2:

Funx.List.group_sort(hand)

# [
#   [%Game.Card{id: "14467", rank: "4", suit: "D"}],
#   [
#     %Game.Card{id: "13859", rank: "4", suit: "H"}, 
#     %Game.Card{id: "14435", rank: "4", suit: "H"}
#   ],
#   [%Game.Card{id: "13891", rank: "10", suit: "H"}],
#   [%Game.Card{id: "13923", rank: "4", suit: "S"}],
#   [%Game.Card{id: "13955", rank: "10", suit: "S"}]
# ]

And again, we can implement a different Ord, such as order by suit:

Funx.List.group_sort(hand, Card.suit_ord)

# [
#   [%Game.Card{id: "14467", rank: "4", suit: "D"}],
#   [
#     %Game.Card{id: "13891", rank: "10", suit: "H"},
#     %Game.Card{id: "13859", rank: "4", suit: "H"},
#     %Game.Card{id: "14435", rank: "4", suit: "H"}
#   ],
#   [
#     %Game.Card{id: "13955", rank: "10", suit: "S"}, 
#     %Game.Card{id: "13923", rank: "4", suit: "S"}
#   ]
# ]

Summary

Equality should be a domain rule. Define a default equality at the type level using eq_for/2 so the rest of the code inherits it, use Prism when structure is optional and Lens when invariants are required, and use Funx list operations like elem? and uniq to respect domain semantics.

Resources

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.