Funx.Eq DSL
Mix.install([
{:funx, github: "JKWA/funx", ref: "feeaaf3"}
])
Overview
The Funx.Eq module provides a declarative DSL syntax for building complex equality comparators by composing projections and boolean structure.
The DSL defines Eq logic that consumes projections - functions that extract values for comparison. Lens and Prism optics are one family of reusable projections the DSL accepts. The boolean structure (AND/OR) allows you to express “equal if X matches AND Y matches” or “equal if X OR Y matches” in a clear, composable way.
Key Features:
-
Declarative
onanddiff_ondirectives for equality -
Nested
any(OR) andall(AND) blocks for complex logic - Support for atoms, lenses, prisms, functions, and custom behaviours
- Prisms for sum type projection (variants, optional values, tagged unions)
- Type-based filtering with bare struct modules
Quick Reference
DSL Syntax:
eq do
on # Field/projection must be equal
diff_on # Field/projection must differ
any do ... end # At least one nested check must pass (OR)
all do ... end # All nested checks must pass (AND)
end
Valid Projections:
-
Atoms:
:field_name, converts toPrism.key(:field_name) -
Lenses:
Lens.key(:field),Lens.path([:a, :b]) -
Prisms:
Prism.key(:field),Prism.path([{Struct, :field}]) -
Traversals:
Traversal.all(),Traversal.filter(pred) -
Functions:
&String.length/1,fn x -> x.field end -
Helper functions:
MyModule.my_projection() -
Behaviour modules:
MyBehaviour(returns Eq map) -
Bare struct modules:
MyStruct(for type filtering)
Options:
-
or_else: value- Fallback value when Prism returns Nothing (only with atoms and Prisms) -
eq: module- Custom Eq module for comparison
Setup
Use the DSL and alias helper modules:
use Funx.Eq
alias Funx.Optics.{Lens, Prism}
alias Funx.Eq
Define a test struct:
defmodule Person do
defstruct [:name, :age, :score, :email, :username, :id]
end
Create sample data:
alice = %Person{name: "Alice", age: 30, score: 100, email: "alice@example.com", id: 1}
bob = %Person{name: "Bob", age: 25, score: 50, email: "bob@example.com", id: 2}
charlie = %Person{name: "Charlie", age: 30, score: nil, email: "charlie@example.com", id: 3}
Basic on Directive
The simplest equality checks use atoms to reference struct fields.
Single Field
eq_by_name =
eq do
on :name
end
Eq.eq?(alice, %Person{name: "Alice", age: 99}, eq_by_name)
Eq.eq?(alice, bob, eq_by_name)
Multiple Fields (Implicit AND)
When you list multiple on directives, ALL fields must match:
eq_name_and_age =
eq do
on :name
on :age
end
Eq.eq?(alice, %Person{name: "Alice", age: 30}, eq_name_and_age)
Different age returns false:
Eq.eq?(alice, %Person{name: "Alice", age: 99}, eq_name_and_age)
diff_on Directive
The diff_on directive checks that fields must DIFFER:
# Same person, different records (different IDs)
eq_same_person_diff_record =
eq do
on :name
on :email
diff_on :id
end
Eq.eq?(
%Person{name: "Alice", email: "alice@example.com", id: 1},
%Person{name: "Alice", email: "alice@example.com", id: 2},
eq_same_person_diff_record
)
Same ID means not equal (diff_on requires IDs to differ):
Eq.eq?(
%Person{name: "Alice", email: "alice@example.com", id: 1},
%Person{name: "Alice", email: "alice@example.com", id: 1},
eq_same_person_diff_record
)
Equivalence Relations and diff_on
Core Eq forms an equivalence relation with three properties:
-
Reflexive:
eq?(a, a)is always true -
Symmetric: If
eq?(a, b)theneq?(b, a) -
Transitive: If
eq?(a, b)andeq?(b, c)theneq?(a, c)
These properties guarantee that Core Eq partitions values into equivalence classes, making it safe for use with Enum.uniq/2, MapSet, and grouping operations.
Extended Eq with diff_on steps outside these laws. It expresses boolean predicates rather than equivalence constraints, and does not guarantee transitivity.
Here’s an example showing how diff_on violates transitivity:
a = %Person{name: "Alice", id: 1}
b = %Person{name: "Alice", id: 2}
c = %Person{name: "Alice", id: 1}
eq_diff_id =
eq do
on :name
diff_on :id
end
IO.puts("a == b: #{Eq.eq?(a, b, eq_diff_id)}")
IO.puts("b == c: #{Eq.eq?(b, c, eq_diff_id)}")
IO.puts("a == c: #{Eq.eq?(a, c, eq_diff_id)}")
Even though a == b and b == c, we have a != c, violating transitivity.
Important: If you need equivalence classes (grouping, uniq, set membership), do not use diff_on.
Prisms and Sum Types
Prisms are for sum types - when a value can be one of several different variants.
Payment Method Example
Consider a payment that can be either a credit card or a check. Define the structs:
defmodule CreditCard do
defstruct [:id, :name, :number, :expiry, :amount]
end
defmodule Check do
defstruct [:id, :name, :routing_number, :account_number, :amount]
end
defmodule Transaction do
defstruct [:payment]
end
Create sample data with different payment types:
cc_payment = %CreditCard{id: 1, name: "John", number: "4532-1111", expiry: "12/26", amount: 100}
check_payment = %Check{id: 2, name: "Dave", routing_number: "111000025", account_number: "987654", amount: 100}
cc_transaction = %Transaction{payment: cc_payment}
check_transaction = %Transaction{payment: check_payment}
The payment field is a sum type: CreditCard | Check.
Projecting into One Variant
Using a Lens on a sum type field compares the values directly, regardless of their variant:
alias Funx.Optics.Prism
eq_by_card_number =
eq do
on Lens.path([:payment, :amount])
end
Eq.eq?(cc_transaction, check_transaction, eq_by_card_number)
Using a Prism instead focuses on a specific variant. This returns false because one is a Check and the other is a CreditCard:
eq_by_card_number =
eq do
on Prism.path([{Transaction, :payment}, {CreditCard, :amount}])
end
Eq.eq?(cc_transaction, check_transaction, eq_by_card_number)
When both projections return Nothing (both are the wrong variant), the Eq succeeds. This represents “this Eq does not apply to these variants”:
Eq.eq?(
%Transaction{payment: %CreditCard{number: "4532-1111"}},
%Transaction{payment: %Check{routing_number: "111000025"}},
eq_by_card_number
)
Comparing by Check Routing Number
Create a Prism using a function that focuses on the Check variant:
alias Funx.Monad.Maybe
eq_by_routing =
eq do
on fn
%Check{routing_number: routing} -> Maybe.just(routing)
_ -> Maybe.nothing()
end
end
Two Check payments with the same routing number are equal:
Eq.eq?(
%Transaction{payment: %Check{routing_number: "111000025"}},
%Transaction{payment: %Check{routing_number: "111000025"}},
eq_by_routing
)
CreditCard doesn’t match the Check Prism (returns Nothing), so CreditCard and Check are considered equal under this Eq:
Eq.eq?(
%Transaction{payment: %CreditCard{number: "4532-1111"}},
%Transaction{payment: %Check{routing_number: "111000025"}},
eq_by_routing
)
Nested any Blocks (OR Logic)
The any block succeeds if AT LEAST ONE nested check passes. Equal if email OR username matches:
eq_contact =
eq do
any do
on :email
on :username
end
end
Email matches, username differs:
Eq.eq?(
%Person{email: "alice@example.com", username: "alice"},
%Person{email: "alice@example.com", username: "alice123"},
eq_contact
)
Neither field matches:
Eq.eq?(
%Person{email: "alice@example.com", username: "alice"},
%Person{email: "bob@example.com", username: "bob"},
eq_contact
)
Mixed on and any
Name must match AND (email OR username must match):
eq_mixed =
eq do
on :name
any do
on :email
on :username
end
end
Eq.eq?(
%Person{name: "Alice", email: "a@example.com", username: "alice"},
%Person{name: "Alice", email: "a@example.com", username: "different"},
eq_mixed
)
Nested all Blocks (Explicit AND)
The all block makes AND logic explicit (though top-level is already AND):
eq_explicit =
eq do
all do
on :name
on :age
end
any do
on :email
on :username
end
end
Eq.eq?(
%Person{name: "Alice", age: 30, email: "a@example.com", username: "alice"},
%Person{name: "Alice", age: 30, email: "a@example.com", username: "different"},
eq_explicit
)
Deep Nesting
Blocks can be nested arbitrarily deep for complex logic. This checks: Name matches AND (email matches OR (age AND username match)):
eq_deep =
eq do
on :name
any do
on :email
all do
on :age
on :username
end
end
end
Eq.eq?(
%Person{name: "Alice", email: "a@example.com"},
%Person{name: "Alice", email: "a@example.com"},
eq_deep
)
Explicit Lens Projections
Lenses provide total access - they assume fields exist.
Simple Fields
eq_by_age =
eq do
on Lens.key(:age)
end
Eq.eq?(alice, charlie, eq_by_age)
Nested Paths
Define a nested struct for testing:
defmodule Address do
defstruct [:city, :state]
end
Use Lens.path to access nested fields:
alice_tx = %Person{
name: "Alice",
email: %Address{city: "Austin", state: "TX"}
}
bob_tx = %Person{
name: "Bob",
email: %Address{city: "Austin", state: "TX"}
}
eq_by_city =
eq do
on Lens.path([:email, :city])
end
Eq.eq?(alice_tx, bob_tx, eq_by_city)
Using Prism.key with Built-in Prisms
The DSL also supports using Funx’s built-in Prism optics explicitly:
eq_explicit =
eq do
on Prism.key(:name)
end
Eq.eq?(alice, %Person{name: "Alice"}, eq_explicit)
Prism Fallback Values
When a Prism returns :nothing, you can provide a fallback value using tuple syntax:
eq_with_fallback =
eq do
on {Prism.key(:score), 0}
end
Eq.eq?(charlie, %Person{score: 0}, eq_with_fallback)
Alternatively, use the or_else: option for the same effect:
eq_with_option =
eq do
on Prism.key(:score), or_else: 0
end
Eq.eq?(charlie, %Person{score: 0}, eq_with_option)
Traversal Projections
Traversals focus on multiple elements simultaneously. All foci must be equal for the comparison to succeed.
Combining Multiple Fields
Traversal.combine lets you check multiple fields at once. Both name and age must match:
alias Funx.Optics.Traversal
eq_name_and_age =
eq do
on Traversal.combine([Lens.key(:name), Lens.key(:age)])
end
Eq.eq?(
%Person{name: "Alice", age: 30},
%Person{name: "Alice", age: 30},
eq_name_and_age
)
Age differs (ALL foci must match):
Eq.eq?(
%Person{name: "Alice", age: 30},
%Person{name: "Alice", age: 25},
eq_name_and_age
)
With Prisms
Traversals work with Prisms, but if any focus returns Nothing, equality fails:
eq_traversal_prism =
eq do
on Traversal.combine([Prism.key(:name), Prism.key(:score)])
end
When both projections return Just and the values match, equality succeeds:
Eq.eq?(
%Person{name: "Alice", score: 100},
%Person{name: "Alice", score: 100},
eq_traversal_prism
)
When one projection returns Nothing (nil score), equality fails:
Eq.eq?(
%Person{name: "Alice", score: nil},
%Person{name: "Alice", score: 100},
eq_traversal_prism
)
Function Projections
Use functions for custom transformations.
Anonymous Functions
eq_name_length =
eq do
on fn p -> String.length(p.name) end
end
Eq.eq?(alice, %Person{name: "Bobby"}, eq_name_length)
Captured Functions
strings = ["apple", "pear"]
eq_by_length =
eq do
on &String.length/1
end
Eq.eq?(Enum.at(strings, 0), Enum.at(strings, 1), eq_by_length)
Helper Function Projections
Define reusable projections as 0-arity helper functions:
defmodule ProjectionHelpers do
alias Funx.Optics.{Lens, Prism}
def name_prism, do: Prism.key(:name)
def age_lens, do: Lens.key(:age)
end
Using Helper Functions
Call the helper functions in the DSL:
eq_by_helpers =
eq do
on ProjectionHelpers.age_lens()
on ProjectionHelpers.name_prism()
end
Eq.eq?(alice, %Person{age: 30, name: "Alice"}, eq_by_helpers)
Helper Functions with or_else
Helper functions work with the or_else option:
eq_score_helper =
eq do
on ProjectionHelpers.name_prism(), or_else: "Unknown"
end
Eq.eq?(%Person{name: nil}, %Person{name: "Unknown"}, eq_score_helper)
Behaviour Module Equality
For complex equality logic, implement the Funx.Eq.Dsl.Behaviour.
Simple Behaviour Module
The eq/1 callback receives options and returns an Eq map:
defmodule UserById do
@behaviour Funx.Eq.Dsl.Behaviour
@impl true
def eq(_opts) do
Eq.contramap(&(&1.id))
end
end
Behaviour with Options
The options parameter allows runtime configuration:
defmodule UserByName do
@behaviour Funx.Eq.Dsl.Behaviour
@impl true
def eq(opts) do
case_sensitive = Keyword.get(opts, :case_sensitive, true)
if case_sensitive do
Eq.contramap(&(&1.name))
else
Eq.contramap(fn p -> String.downcase(p.name) end)
end
end
end
Using Behaviour Modules
Reference the behaviour module directly in the DSL:
eq_by_id =
eq do
on UserById
end
Same ID means equal, even with different names:
Eq.eq?(
%Person{id: 1, name: "Alice"},
%Person{id: 1, name: "Bob"},
eq_by_id
)
With Options
Pass options to configure the behaviour:
eq_name_ci =
eq do
on UserByName, case_sensitive: false
end
Eq.eq?(%Person{name: "Alice"}, %Person{name: "ALICE"}, eq_name_ci)
Protocol Dispatch
LiveBook does not handle creating new protocols
Use Funx.Eq.Protocol for protocol-based equality:
defmodule CaseInsensitiveString do
defstruct [:value]
end
defimpl Funx.Eq.Protocol, for: CaseInsensitiveString do
def eq?(a, b) do
String.downcase(a.value) == String.downcase(b.value)
end
def not_eq?(a, b) do
!eq?(a, b)
end
end
eq_default =
eq do
on Funx.Eq
end
Eq.eq?(
%CaseInsensitiveString{value: "Hello"},
%CaseInsensitiveString{value: "hello"},
eq_default
)
Bare Struct Modules (Type Filtering)
Bare struct modules compile to a type filter that returns true only for matching types:
eq_type_check =
eq do
on Check
end
Both values are Check structs, so they’re equal (field values are ignored):
Eq.eq?(%Check{id: 1}, %Check{id: 2}, eq_type_check)
Different types are not equal:
Eq.eq?(%Check{id: 1}, %CreditCard{id: 1}, eq_type_check)
Empty eq Block
An empty eq block returns an identity equality where all values are equal:
eq_identity =
eq do
end
Eq.eq?(alice, bob, eq_identity)
This always returns true, treating all values as equal.
Custom Eq Option
Use the eq: option to specify a custom Eq module for a field.
Define a custom Eq module by implementing the behaviour:
defmodule CaseInsensitiveEq do
@behaviour Funx.Eq.Dsl.Behaviour
@impl true
def eq(_opts) do
%{
eq?: fn a, b -> String.downcase(a) == String.downcase(b) end,
not_eq?: fn a, b -> String.downcase(a) != String.downcase(b) end
}
end
end
Use it in the DSL with the eq: option:
eq_custom =
eq do
on :name, eq: CaseInsensitiveEq
end
Eq.eq?(%Person{name: "Alice"}, %Person{name: "ALICE"}, eq_custom)
Using Ord for Equality
Any Ord (ordering) can be converted to an Eq using Funx.Ord.Eq.to_eq/1. This is useful when you have an existing ordering and want to derive equality from it.
Key insight: Two values are equal if and only if their comparison returns :eq.
Converting Ord to Eq
Create an Ord and convert it to an Eq:
alias Funx.Ord, as: OrdUtils
length_ord = OrdEq.contramap(&String.length/1)
length_eq = OrdEq.to_eq(length_ord)
length_eq.eq?.("hello", "world")
Different lengths are not equal:
length_eq.eq?.("hi", "hello")
Using Ord in the DSL
You can use an Ord-derived Eq in the DSL with the eq: option:
name_ord = OrdEq.contramap(fn name -> String.downcase(name) end)
eq_by_name =
eq do
on :name, eq: OrdEq.to_eq(name_ord)
end
Eq.eq?(%Person{name: "Alice"}, %Person{name: "ALICE"}, eq_by_name)
When to Use Ord-derived Eq
Use Ord.Eq.to_eq/1 when:
-
You already have an
Orddefined for your domain - You want equality to match the ordering semantics
- You’re refining an existing ordering to check specific fields
Note that any Ord naturally defines an equivalence relation (the :eq case), so this is always safe.
Compile-Time Validation
The DSL validates projections at compile time and raises helpful errors.
Invalid Projection Type
Maps and other unsupported types raise a CompileError:
eq do
on %{invalid: :map}
end
Incorrect or_else Usage
The or_else option only works with atoms and Prisms, not with Lens:
eq do
on Lens.key(:name), or_else: "Unknown"
end
Redundant or_else
Providing or_else when the projection already has a fallback value:
eq do
on {Prism.key(:score), 0}, or_else: 10
end
Summary
The Eq DSL provides a declarative, type-safe way to build complex equality comparators:
- Atoms as projections for simple field access (safe for nil/missing values)
- Lenses for product types (total access to all fields)
- Prisms for sum types (projection into one variant)
- Traversals for focusing on multiple elements (all, filter, etc.)
- Functions for custom transformations
- Behaviours for reusable equality logic
- Bare structs for type-based filtering
-
anyblocks for OR logic -
allblocks for explicit AND logic -
diff_onfor non-equivalence constraints (escape hatch from Eq laws)
All projections compose with AND logic at the top level, while any blocks provide OR logic. This makes it easy to express complex equality rules like “equal if (A and B) or (C and D)” without nested conditionals.