Powered by AppSignal & Oban Pro

Funx.Eq DSL

livebooks/eq/eq_dsl.livemd

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 on and diff_on directives for equality
  • Nested any (OR) and all (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 to Prism.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) then eq?(b, a)
  • Transitive: If eq?(a, b) and eq?(b, c) then eq?(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 Ord defined 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
  • any blocks for OR logic
  • all blocks for explicit AND logic
  • diff_on for 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.