Funx.Predicate DSL
Mix.install([
{:funx, "0.8.4"}
])
Overview
The Funx.Predicate module provides a declarative DSL for building complex predicates (boolean algebra) by composing conditions with logical operators.
Key Features:
- Declarative predicate composition with implicit AND
-
negatedirective for logical negation -
checkdirective for projection-based predicates -
Nested
any(OR) andall(AND) blocks for complex logic - Built-in predicates for common checks
-
Works seamlessly with
Enum.filter/2,Enum.find/2, and other predicate-accepting functions
Mental Model
-
A predicate is a function
a -> boolean -
predbuilds a predicate by composing smaller predicates - Top-level composition is AND
-
anyis OR,allis AND -
Empty
any= false, emptyall= true -
negate X= NOT X -
check projection, predappliespredonly if the projection matches. Otherwise, the check returns false. -
check projection(no predicate) defaults to truthy check (!!value) -
Projections never pass
nilto predicates
Quick Reference
DSL Syntax
pred do
# Bare predicate must pass
negate # Predicate must fail
check # Projection + default truthy check (!!value)
check , # Projection + predicate composition
negate check , # Negated projection (value must NOT match)
any do ... end # At least one nested predicate must pass (OR)
all do ... end # All nested predicates must pass (AND)
negate_all do ... end # NOT (all predicates pass) - De Morgan's Laws
negate_any do ... end # NOT (any predicate passes) - De Morgan's Laws
end
Valid Predicates
-
Functions:
&adult?/1,fn user -> user.age >= 18 end -
Variables:
is_verified,check_active -
Behaviour modules:
IsActive,{HasMinimumAge, minimum: 21} -
Built-in predicates:
Required,Integer,{Eq, value: :active},{MinLength, min: 3}
Valid Projections
-
Atoms:
:field_name→Prism.key(:field_name)(present | absent) -
Lists:
[:a, :b]→Prism.path([:a, :b])(nested keys and structs) -
Lenses:
Lens.key(:field)(total accessor - always succeeds) -
Prisms:
Prism.struct(Variant)(branch selection) -
Traversals:
Traversal.combine([...])(multiple foci as list) -
Functions:
&(&1.field),fn x -> x.value end
Setup
use Funx.Predicate
alias Funx.Optics.{Lens, Prism, Traversal}
defmodule User do
defstruct [:name, :age, :active, :verified, :email, :role]
end
defmodule Order do
defstruct [:id, :total, :status, :items]
end
defmodule OrderStatus.Pending do
defstruct []
end
defmodule OrderStatus.Completed do
defstruct [:total, :completed_at]
end
defmodule OrderStatus.Cancelled do
defstruct [:reason]
end
alice = %User{name: "Alice", age: 30, active: true, verified: true, email: "alice@example.com", role: :admin}
bob = %User{name: "Bob", age: 17, active: true, verified: false, email: "bob@example.com", role: :user}
charlie = %User{name: "Charlie", age: 25, active: false, verified: true, email: "charlie@example.com", role: :moderator}
users = [alice, bob, charlie]
Basic Predicates
Multiple predicates at top level are combined with AND:
adult? = fn user -> user.age >= 18 end
active? = fn user -> user.active end
verified? = fn user -> user.verified end
check_eligible =
pred do
adult?
active?
verified?
end
# Only Alice passes all three
Enum.filter(users, check_eligible)
negate: Logical Negation
minor? = fn user -> user.age < 18 end
check_adult_active =
pred do
negate minor?
active?
end
Enum.filter(users, check_adult_active)
check: Projection-Based Predicates
The check directive composes a projection with a predicate.
Atom Fields (Prism.key)
Atoms convert to Prism.key/1. When field is absent or nil, check returns false:
check_long_name =
pred do
check :name, fn name -> String.length(name) > 5 end
end
check_long_name.(alice) # true - "Alice" has 5 chars... wait, that's exactly 5
check_long_name.(charlie) # true - "Charlie" > 5
check_long_name.(%User{name: nil}) # false - nil, predicate never runs
Lens (Total Access)
Lenses always succeed - use for required fields:
check_adult_by_age =
pred do
check Lens.key(:age), fn age -> age >= 18 end
end
check_adult_by_age.(alice) # true
check_adult_by_age.(bob) # false
Prism (Sum Type Branch Selection)
Prisms select one case of a sum type. Predicate only runs when branch matches:
order_pending = %Order{id: 1, status: %OrderStatus.Pending{}}
order_completed = %Order{id: 2, status: %OrderStatus.Completed{total: 500, completed_at: ~U[2024-01-15 10:00:00Z]}}
order_cancelled = %Order{id: 3, status: %OrderStatus.Cancelled{reason: "Out of stock"}}
check_high_value_completed =
pred do
check Prism.path([:status, OrderStatus.Completed, :total]), fn total -> total >= 500 end
end
check_high_value_completed.(order_completed) # true - branch matches, total >= 500
check_high_value_completed.(order_pending) # false - branch doesn't match, excluded
List Paths (Nested Access)
defmodule Account do
defstruct [:owner, :settings]
end
defmodule Owner do
defstruct [:name, :age]
end
defmodule Settings do
defstruct [:notifications, :privacy]
end
accounts = [
%Account{owner: %Owner{name: "Alice", age: 30}, settings: %Settings{notifications: true, privacy: :public}},
%Account{owner: %Owner{name: "Bob", age: 17}, settings: %Settings{notifications: false, privacy: :private}},
%Account{owner: %Owner{name: "Charlie", age: 25}, settings: %Settings{notifications: true, privacy: :private}}
]
check_adult_with_notifications =
pred do
check [:owner, :age], fn age -> age >= 18 end
check [:settings, :notifications], fn n -> n == true end
end
Enum.filter(accounts, check_adult_with_notifications)
Traversal (Relating Multiple Foci)
Traversal predicates receive a list of all focused values:
defmodule Transaction do
defstruct [:charge_amount, :refund_amount, :status]
end
check_valid_refund =
pred do
check Traversal.combine([Lens.key(:charge_amount), Lens.key(:refund_amount)]), fn
[charge, refund] -> charge == refund
_ -> false
end
end
check_valid_refund.(%Transaction{charge_amount: 100, refund_amount: 100, status: :refunded}) # true
check_valid_refund.(%Transaction{charge_amount: 100, refund_amount: 50, status: :refunded}) # false
negate check: Excluding Matches
# Adult who is NOT banned (active != false)
valid_user =
pred do
check :age, fn age -> age >= 18 end
negate check :active, fn active -> active == false end
end
valid_user.(alice) # true
valid_user.(charlie) # false - not active
any and all: Boolean Logic
OR Logic with any
check_admin_or_verified =
pred do
any do
fn user -> user.role == :admin end
fn user -> user.verified end
end
end
Enum.filter(users, check_admin_or_verified) # Alice (admin), Charlie (verified)
Nested Blocks
Active AND (admin OR (verified AND adult)):
check_complex =
pred do
active?
any do
fn user -> user.role == :admin end
all do
verified?
adult?
end
end
end
Enum.filter(users, check_complex)
De Morgan’s Laws
negate_all and negate_any apply De Morgan’s Laws:
-
negate_all: NOT (A AND B) → (NOT A) OR (NOT B) - passes if at least one fails -
negate_any: NOT (A OR B) → (NOT A) AND (NOT B) - passes only if all fail
# Regular users only (not vip, not sponsor, not admin)
regular_user =
pred do
negate_any do
fn user -> user.vip end
fn user -> user.sponsor end
fn user -> user.role == :admin end
end
end
regular_user.(%{vip: false, sponsor: false, role: :user}) # true
regular_user.(%{vip: true, sponsor: false, role: :user}) # false
Behaviour Modules
For reusable, configurable predicates:
defmodule HasMinimumAge do
@behaviour Funx.Predicate.Dsl.Behaviour
@impl true
def pred(opts) do
minimum = Keyword.get(opts, :minimum, 18)
fn user -> user.age >= minimum end
end
end
check_21_plus =
pred do
{HasMinimumAge, minimum: 21}
end
check_21_plus.(alice) # true - age 30
check_21_plus.(%User{age: 19}) # false
Built-in Predicates
Funx provides built-in predicate modules:
alias Funx.Predicate.{
Eq, NotEq, In, NotIn,
LessThan, LessThanOrEqual, GreaterThan, GreaterThanOrEqual,
IsTrue, IsFalse,
MinLength, MaxLength, Pattern,
Integer, Positive, Negative,
Required, Contains
}
Examples
# Equality and membership
check_status =
pred do
check :status, {Eq, value: :active}
check :role, {In, values: [:admin, :moderator, :user]}
end
check_status.(%{status: :active, role: :admin}) # true
check_status.(%{status: :inactive, role: :admin}) # false
# Comparison predicates
check_adult =
pred do
check :age, {GreaterThanOrEqual, value: 18}
end
check_adult.(%{age: 21}) # true
check_adult.(%{age: 16}) # false
# String validation
valid_username =
pred do
check :username, {MinLength, min: 3}
check :username, {MaxLength, max: 20}
check :email, {Pattern, regex: ~r/@/}
end
valid_username.(%{username: "alice", email: "alice@example.com"}) # true
valid_username.(%{username: "ab", email: "alice@example.com"}) # false
# Numeric predicates
valid_quantity =
pred do
check :quantity, Integer
check :quantity, Positive
end
valid_quantity.(%{quantity: 5}) # true
valid_quantity.(%{quantity: -1}) # false
valid_quantity.(%{quantity: 5.5}) # false
Default Truthy vs Required
When check has no predicate, it defaults to truthy (!!value):
truthy_check =
pred do
check :name # equivalent to: fn v -> !!v end
end
required_check =
pred do
check :name, Required # not nil AND not ""
end
# Empty string is truthy but not Required
truthy_check.(%{name: ""}) # true
required_check.(%{name: ""}) # false
# false is falsy but passes Required (it's a present value)
truthy_check.(%{name: false}) # false
required_check.(%{name: false}) # true
| Value |
check :field (truthy) |
Required |
IsTrue |
|---|---|---|---|
"hello" |
true | true | false |
"" |
true | false | false |
nil |
false | false | false |
false |
false | true | false |
true |
true | true | true |
0 |
true | true | false |
Integration with Enum
check_eligible =
pred do
check :age, {GreaterThanOrEqual, value: 18}
check :verified, IsTrue
end
# Filter, find, count, partition
Enum.filter(users, check_eligible)
Enum.find(users, check_eligible)
Enum.count(users, check_eligible)
Enum.split_with(users, check_eligible)
Common Mistakes
-
Assuming
check :fieldpassesnil— It never does. When a field is absent ornil, the check returns false and your predicate never runs. -
Treating Prism like safe field access — Prism selects a branch, it doesn’t extract a field. If the value doesn’t match the selected case, the predicate is skipped entirely.
-
Expecting Traversal predicates to run per element — They receive a list. Your predicate gets
[value1, value2, ...], not each value individually.
Summary
The Predicate DSL provides declarative, composable boolean filters:
- Bare predicates for simple function composition
-
negatefor logical negation -
checkdirective for projection-based predicates (default truthy, or explicit predicate) - Lenses for total field access
- Prisms for sum type branch selection
- Atoms as degenerate sums (present | absent)
- Traversals for relating multiple foci
-
Built-in predicates:
Eq,In,LessThan,GreaterThan,MinLength,Pattern,Required,Integer,Positive, and more -
any/allblocks for OR/AND logic -
negate_all/negate_anyfor De Morgan’s Laws
Works seamlessly with Enum.filter, Enum.find, and other predicate-accepting functions.