Funx: Equality as a Domain Rule
“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.
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.