Funx: Free Your Predicates
“A complex system that works is invariably found to have evolved from a simple system that worked.” — John Gall
What is Free?
Functional programming likes to borrow mathematical terms, one of which is free.
In FP, free means: build a description first, interpret it later.
Here are a couple of basic boolean functions:
positive? = fn x -> x > 0 end
even? = fn x -> rem(x, 2) == 0 end
When we apply them, they collapse to booleans:
positive?.(2) # true
positive?.(-2) # false
even?.(2) # true
even?.(1) # false
We can combine them:
positive_and_even? = fn x -> positive?.(x) and even?.(x) end
positive_or_even? = fn x -> positive?.(x) or even?.(x) end
positive_and_even?.(1) # false
positive_and_even?.(2) # true
positive_and_even?.(-2) # false
positive_or_even?.(1) # true
positive_or_even?.(2) # true
positive_or_even?.(-2) # true
positive_or_even?.(-1) # false
We run the checks inline and bake in the grouping (and/or).
Funx has p_all/1 and p_any/1, which take lists of predicates and lets us declare the grouping logic:
positive_and_even? = Funx.Predicate.p_all([positive?, even?])
positive_or_even? = Funx.Predicate.p_any([positive?, even?])
positive_and_even?.(1) # false
positive_or_even?.(1) # true
Funx also has a predicate DSL, which can be easier to read, particularly for more complex logic.
use Funx.Predicate
positive_and_even? =
pred do
positive?
even?
end
positive_or_even? =
pred do
any do
positive?
even?
end
end
positive_and_even?.(1) # false
positive_or_even?.(1) # true
Let’s look at a role-playing game.
The Rules
First, we define our game’s rules:
| Predicate | Rule |
|---|---|
poisoned? |
Poison is active |
poison_resistant? |
Blessing grants poison resistance |
poison_danger? |
Poisoned AND NOT resistant |
bleeding? |
Bleeding is NOT staunched |
severe_bleeding? |
Bleeding AND moderate+ |
wet? |
Water exposure is wet OR soaked |
charge_building? |
Electrical charge building |
electrocution_danger? |
Wet AND charge building |
exhausted? |
Stamina below 25% |
collapsed? |
Stamina below 10% |
death_spiral? |
Exhausted AND bleeding |
mortal_danger? |
Any mortal danger |
can_staunch? |
Bleeding AND has bandage |
can_cure_poison? |
Poisoned AND has antidote |
We can express these with predicates:
defmodule Status do
defstruct [:poison, :bleeding, :exposure, :stamina, :blessing, :inventory]
use Funx.Predicate
def poisoned? do
pred do
check [:poison, :active], fn active -> active == true end
end
end
def bleeding? do
pred do
check [:bleeding, :staunched], fn staunched -> staunched == false end
end
end
def poison_resistant? do
pred do
check [:blessing, :grants], fn grants -> :poison_resistance in grants end
end
end
def poison_danger? do
pred do
poisoned?()
negate poison_resistant?()
end
end
def severe_bleeding? do
pred do
bleeding?()
check [:bleeding, :severity], fn severity -> severity in [:moderate, :severe, :critical] end
end
end
def wet? do
pred do
check [:exposure, :water], fn water -> water in [:wet, :soaked] end
end
end
def charge_building? do
pred do
check [:exposure, :electricity], fn electricity -> electricity == :building end
end
end
def electrocution_danger? do
pred do
wet?()
charge_building?()
end
end
def exhausted? do
pred do
check :stamina, fn s -> s.current / s.max < 0.25 end
end
end
def collapsed? do
pred do
check :stamina, fn s -> s.current / s.max < 0.1 end
end
end
def death_spiral? do
pred do
exhausted?()
bleeding?()
end
end
def mortal_danger? do
pred do
any do
electrocution_danger?()
death_spiral?()
severe_bleeding?()
collapsed?()
end
end
end
def can_staunch? do
pred do
bleeding?()
check [:inventory, :bandage], fn count -> count > 0 end
end
end
def can_cure_poison? do
pred do
poisoned?()
check [:inventory, :antidote], fn count -> count > 0 end
end
end
end
Take a minute to look at this code. These are free predicates. They describe our domain rules without being embedded in control flow, and they are not executed until we interpret them.
When we return in six months, that separation matters. We can quickly see what facts exist, how they build on one another, and where to make a change when the rules evolve, without having to hunt through application logic.
If we have done our job correctly, our subject matter experts should be able to read through these functions and confirm the rules.
The Character
A character has a name and a status:
defmodule Character do
alias Funx.Optics.Lens
defstruct [:name, :status]
def status_lens, do: Lens.key(:status)
def status_check(%__MODULE__{} = character) do
status = Lens.view!(character, status_lens())
%{
poisoned: Status.poisoned?.(status),
poison_resistant: Status.poison_resistant?.(status),
poison_danger: Status.poison_danger?.(status),
bleeding: Status.bleeding?.(status),
severe_bleeding: Status.severe_bleeding?.(status),
electrocution_danger: Status.electrocution_danger?.(status),
exhausted: Status.exhausted?.(status),
collapsed: Status.collapsed?.(status),
death_spiral: Status.death_spiral?.(status),
mortal_danger: Status.mortal_danger?.(status)
}
end
def actions(%__MODULE__{} = character) do
status = Lens.view!(character, status_lens())
%{
can_staunch: Status.can_staunch?.(status),
can_cure_poison: Status.can_cure_poison?.(status)
}
end
end
Here, we are interpreting all of our predicates in two functions, status_check/1 and actions/1.
A character in trouble
Let’s start with a character:
warrior = %Character{
name: "Wounded Warrior",
status: %Status{
poison: %{active: true, source: :spider, severity: :moderate},
bleeding: %{severity: :light, staunched: false},
exposure: %{water: :soaked, electricity: :building},
stamina: %{current: 20, max: 100},
blessing: %{grants: [:poison_resistance]},
inventory: %{antidote: 1, bandage: 2}
}
}
When we apply the status_check/1:
Character.status_check(warrior)
# %{
# bleeding: true,
# poisoned: true,
# poison_resistant: true,
# poison_danger: false,
# severe_bleeding: false,
# electrocution_danger: true,
# exhausted: true,
# collapsed: false,
# death_spiral: true,
# mortal_danger: true
# }
We find our character is in danger:
- Poisoned, but resistant: no poison danger
- Bleeding, but light: no severe bleeding
- Soaked + charge building: electrocution danger
- Exhausted + bleeding: death spiral
- Mortal danger: true
Character.actions(warrior)
# %{can_staunch: true, can_cure_poison: true}
Fortunately, our warrior has some options: they can_staunch and can_cure_poison.
Let’s have them escape the water and apply a bandage:
warrior_after = %Character{
name: "Wounded Warrior",
status: %Status{
poison: %{active: true, source: :spider, severity: :moderate},
bleeding: %{severity: :light, staunched: true},
exposure: %{water: :dry, electricity: :building},
stamina: %{current: 20, max: 100},
blessing: %{grants: [:poison_resistance]},
inventory: %{antidote: 1, bandage: 1}
}
}
Now when we check their status:
Character.status_check(warrior_after)
# %{
# bleeding: false,
# poisoned: true,
# poison_resistant: true,
# poison_danger: false,
# severe_bleeding: false,
# electrocution_danger: false,
# exhausted: true,
# collapsed: false,
# death_spiral: false,
# mortal_danger: false
# }
They are no longer in immediate danger:
- Electrocution danger: false
- Death spiral: false
- Mortal danger: false
Character.actions(warrior_after)
# %{can_staunch: false, can_cure_poison: true}
Even though they still have a bandage available, they are no longer bleeding, so can_staunch is false. They still have an antidote, so can_cure_poison remains true.
Conclusion
We want our rules to read like rules. We want them named, composed, and grouped in a way we can scan quickly. We want our rules to reflect our domain’s shared language. And when the rules change, we want to be able to quickly dive into the code, make the change, and trust everything built on top will hold.
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.