Model Outcomes with Either
Mix.install([
{:fun_park,
git: "https://github.com/JKWA/funpark_notebooks.git",
branch: "main"
}
])
Advanced Functional Programming with Elixir
|
Interactive Examples from Chapter 9 Advanced Functional Programming with Elixir. |
Structure of Either
```elixir
defmodule FunPark.Monad.Either.Right do
defstruct [:right]
def pure(value), do: %__MODULE__{right: value}
end
```
```elixir
defmodule FunPark.Monad.Either.Left do
defstruct [:left]
def pure(value), do: %__MODULE__{left: value}
end
```
Validation
```elixir
def lift_predicate(predicate, left_fn, value) do
case predicate.(value) do
true -> Right.pure(value)
false -> Left.pure(left_fn.(value))
end
end
```
```elixir
def ensure_height(patron, ride) do
Either.lift_predicate(
Ride.tall_enough?(ride),
&ValidationError.new("#{&1.name} is not tall enough"),
patron
)
end
```
```elixir
def ensure_age(patron, ride) do
Either.lift_predicate(
Ride.old_enough?(ride),
&ValidationError.new("#{&1.name} is not old enough"),
patron
)
end
```
```elixir
defmodule FunPark.Errors.ValidationError do
@moduledoc """
A structured validation error that can accumulate multiple messages.
"""
@enforce_keys [:errors]
defstruct [:errors]
defexception [:errors]
@behaviour Access
defdelegate fetch(term, key), to: Map
defdelegate get_and_update(term, key, fun), to: Map
defdelegate pop(term, key), to: Map
def new(errors) when is_list(errors), do: %__MODULE__{errors: errors}
def new(error) when is_binary(error), do: %__MODULE__{errors: [error]}
def merge(%__MODULE__{errors: e1}, %__MODULE__{errors: e2}) do
%__MODULE__{errors: e1 ++ e2}
end
def exception(value) do
case Keyword.keyword?(value) do
true -> struct(__MODULE__, value)
false -> new(value)
end
end
def message(%__MODULE__{errors: errors}) do
Enum.join(errors, ", ")
end
end
```
Let’s start by generating a couple of patrons and a ride:
alice = FunPark.Patron.make("Alice", 12, 125, ticket_tier: :vip)
beth = FunPark.Patron.make("Beth", 16, 115)
haunted_mansion = FunPark.Ride.make(
"Haunted Mansion",
min_age: 14,
min_height: 120
)
Alice is tall enough but too young:
FunPark.Ride.FastLane.ensure_height(alice, haunted_mansion)
FunPark.Ride.FastLane.ensure_age(alice, haunted_mansion)
When we check Beth, although she meets the age requirement, she is too short:
FunPark.Ride.FastLane.ensure_age(beth, haunted_mansion)
FunPark.Ride.FastLane.ensure_height(beth, haunted_mansion)
Combining Eligibility
```elixir
def ensure_eligibility(patron, ride) do
patron
|> ensure_age(ride)
|> Monad.bind(&ensure_height(&1, ride))
end
```
At this point, neither Alice nor Beth is eligible—but for different reasons:
FunPark.Ride.FastLane.ensure_eligibility(alice, haunted_mansion)
FunPark.Ride.FastLane.ensure_eligibility(beth, haunted_mansion)
Let’s look at Charles, who is both too young and too short:
charles = FunPark.Patron.make("Charles", 13, 115)
FunPark.Ride.FastLane.ensure_eligibility(charles, haunted_mansion)
Now let’s check Dave:
dave = FunPark.Patron.make("Dave", 16, 140)
Dave meets the ride’s eligibility criteria:
FunPark.Ride.FastLane.ensure_eligibility(dave, haunted_mansion)
Ensure a Fast Pass
```elixir
def ensure_fast_pass(patron, ride) do
Either.lift_predicate(
Ride.fast_pass?(patron, ride),
&ValidationError.new("#{&1.name} does not have a fast pass"),
patron
)
end
```
Dave is eligible to ride the Haunted Mansion—but he does not have a fast pass:
FunPark.Ride.FastLane.ensure_fast_pass(dave, haunted_mansion)
Let’s give him one:
datetime = DateTime.new!(~D[2025-06-01], ~T[13:00:00])
fast_pass = FunPark.FastPass.make(haunted_mansion, datetime)
dave = FunPark.Patron.add_fast_pass(dave, fast_pass)
Now, Dave can enter the Haunted Mansion’s fast lane:
FunPark.Ride.FastLane.ensure_fast_pass(dave, haunted_mansion)
But what about Elsie? She doesn’t have a fast pass, but she is a VIP:
elsie = FunPark.Patron.make("Elsie", 17, 135, ticket_tier: :vip)
FunPark.Ride.FastLane.ensure_fast_pass(elsie, haunted_mansion)
Ensure a Fast Pass or VIP Access
```elixir
def ensure_vip_or_fast_pass(patron, ride) do
patron
|> ensure_fast_pass(ride)
|> Either.or_else(&ensure_vip(&1, ride))
end
```
Now, with her VIP status, Elsie has access to the fast lane:
FunPark.Ride.FastLane.ensure_vip_or_fast_pass(elsie, haunted_mansion)
Ensure Fast Lane Access
```elixir
def ensure_fast_pass_lane(patron, ride) do
patron
|> ensure_eligibility(ride)
|> Monad.bind(&ensure_vip_or_fast_pass(&1, ride))
end
```
Let’s see if our patrons can enter the fast lane:
FunPark.Ride.FastLane.ensure_fast_pass_lane(alice, haunted_mansion)
FunPark.Ride.FastLane.ensure_fast_pass_lane(beth, haunted_mansion)
FunPark.Ride.FastLane.ensure_fast_pass_lane(charles, haunted_mansion)
FunPark.Ride.FastLane.ensure_fast_pass_lane(dave, haunted_mansion)
FunPark.Ride.FastLane.ensure_fast_pass_lane(elsie, haunted_mansion)
Ensure Groups of Patrons
```elixir
def ensure_fast_pass_lane_group(patrons, ride) do
Either.traverse(patrons, &ensure_fast_pass_lane(&1, ride))
end
```
As a group, Elsie and Dave can enter the Haunted Mansion’s fast lane:
patrons = [elsie, dave]
FunPark.Ride.FastLane.ensure_fast_pass_lane_group(
patrons,
haunted_mansion
)
But not with Charles:
patrons = [elsie, dave, charles]
FunPark.Ride.FastLane.ensure_fast_pass_lane_group(
patrons,
haunted_mansion
)
If we add patrons after Charles, it still fails the same way:
patrons = [elsie, dave, charles, beth]
FunPark.Ride.FastLane.ensure_fast_pass_lane_group(
patrons,
haunted_mansion
)
Notice that if we swap Charles and Beth, then Beth’s ineligibility is the one we’ll see:
patrons = [elsie, dave, beth, charles]
FunPark.Ride.FastLane.ensure_fast_pass_lane_group(
patrons,
haunted_mansion
)
From Bind to Combine
```elixir
def traverse_a(list, function) do
list
|> Enum.reduce(
Right.pure([]),
fn item, acc_either ->
item_either = function.(item)
Monad.ap(Monad.map(acc_either, &[&1 | []]), item_either)
end
)
|> Monad.map(&Enum.reverse/1)
end
```
```elixir
defprotocol FunPark.Appendable do
def coerce(value)
def append(left, right)
end
```
```elixir
defimpl FunPark.Appendable, for: Any do
def coerce(value), do: [value]
def append(left, right), do: left ++ right
end
```
```elixir
defimpl FunPark.Appendable, for: FunPark.Errors.ValidationError do
alias FunPark.Errors.ValidationError
def coerce(error), do: ValidationError.new(error)
def append(%ValidationError{} = left, %ValidationError{} = right) do
ValidationError.merge(left, right)
end
end
```
Let’s return to our patrons and ride:
alice = FunPark.Patron.make("Alice", 12, 125, ticket_tier: :vip)
beth = FunPark.Patron.make("Beth", 16, 115)
charles = FunPark.Patron.make("Charles", 13, 115)
dave = FunPark.Patron.make("Dave", 16, 140)
elsie = FunPark.Patron.make("Elsie", 17, 135, ticket_tier: :vip)
haunted_mansion = FunPark.Ride.make(
"Haunted Mansion",
min_age: 14,
min_height: 120
)
Next, let’s reuse our ride eligibility logic:
valid_height = FunPark.Utils.curry_r(
&FunPark.Ride.FastLane.ensure_height/2
)
valid_age = FunPark.Utils.curry_r(&FunPark.Ride.FastLane.ensure_age/2)
But instead of chaining them one at a time, create a list of validators:
validators = [
valid_height.(haunted_mansion),
valid_age.(haunted_mansion)
]
Here we traversed over our validators and applied them to Alice, to find she is not old enough:
FunPark.Monad.Either.traverse_a(validators, & &1.(alice))
Let’s apply them to Beth, who is too short:
FunPark.Monad.Either.traverse_a(validators, & &1.(beth))
And Charles, who is both too short and too young:
FunPark.Monad.Either.traverse_a(validators, & &1.(charles))
Let’s check Dave, who is eligible:
FunPark.Monad.Either.traverse_a(validators, & &1.(dave))
```elixir
def validate(value, validators) do
validators
|> traverse_a(& &1.(value))
|> Monad.map(fn _ -> value end)
end
```
Here, Dave is eligible:
FunPark.Monad.Either.validate(dave, validators)
And again, Charles is not:
FunPark.Monad.Either.validate(charles, validators)
```elixir
def validate_eligibility(patron, ride) do
validators = [
&ensure_height(&1, ride),
&ensure_age(&1, ride)
]
Either.validate(patron, validators)
end
```
```elixir
def ensure_online(%FunPark.Ride{online: false} = ride) do
Left.pure(ValidationError.new("#{ride.name} is offline"))
end
def ensure_online(ride), do: Right.pure(ride)
```
```elixir
def validate_fast_pass_lane(patron, ride) do
validators = [
&validate_eligibility(&1, ride),
&ensure_vip_or_fast_pass(&1, ride)
]
ride
|> ensure_online()
|> Monad.map(fn _ -> patron end)
|> Monad.bind(&Either.validate(&1, validators))
end
```
Alice is a VIP, so she doesn’t need a fast pass—but she’s still too young:
FunPark.Ride.FastLane.validate_fast_pass_lane(alice, haunted_mansion)
Beth is too short and lacks a fast pass:
FunPark.Ride.FastLane.validate_fast_pass_lane(beth, haunted_mansion)
Charles is neither tall enough nor old enough—and he also doesn’t have a fast pass:
FunPark.Ride.FastLane.validate_fast_pass_lane(charles, haunted_mansion)
Dave is tall enough and old enough—but doesn’t have a fast pass:
FunPark.Ride.FastLane.validate_fast_pass_lane(dave, haunted_mansion)
Let’s give him one:
datetime = DateTime.new!(~D[2025-06-01], ~T[13:00:00])
fast_pass = FunPark.FastPass.make(haunted_mansion, datetime)
dave = FunPark.Patron.add_fast_pass(dave, fast_pass)
Now Dave can enter the Haunted Mansion’s fast lane:
FunPark.Ride.FastLane.validate_fast_pass_lane(dave, haunted_mansion)
And finally, Elsie—who is eligible and a VIP—can enter the fast lane:
FunPark.Ride.FastLane.validate_fast_pass_lane(elsie, haunted_mansion)
But if we take Haunted Mansion offline:
haunted_mansion = FunPark.Ride.change(
haunted_mansion,
%{online: false}
)
She can no longer enter the fast lane:
FunPark.Ride.FastLane.validate_fast_pass_lane(elsie, haunted_mansion)
```elixir
def validate_fast_pass_lane_group(patrons, ride) do
Either.traverse_a(patrons, &validate_fast_pass_lane(&1, ride))
end
```
Let’s see if our patrons can ride the Haunted Mansion together:
haunted_mansion = FunPark.Ride.change(
haunted_mansion,
%{online: true}
)
patrons = [alice, beth, charles, dave, elsie]
FunPark.Ride.FastLane.validate_fast_pass_lane_group(
patrons,
haunted_mansion
)
No—but it looks like Dave and Elsie can:
patrons = [dave, elsie]
FunPark.Ride.FastLane.validate_fast_pass_lane_group(
patrons,
haunted_mansion
)
Make Errors Explicit
A Store for FunPark
```elixir
def create_table(table_name) when is_atom(table_name) do
try do
table_id = :ets.new(table_name, [:named_table, :public])
Right.pure(table_id)
rescue
e -> Left.pure(e)
end
end
defp when_atom(value) when is_atom(value), do: value
```
```elixir
def drop_table(table_name) when is_atom(table_name) do
try do
:ets.delete(table_name)
Right.pure(:ok)
rescue
e -> Left.pure(e)
end
end
```
```elixir
def insert_item(%_struct{id: id} = item, table_name) when is_atom(table_name) do
try do
data = item |> Map.from_struct() |> Map.delete(:__meta__)
:ets.insert(table_name, {id, data})
Right.pure(item)
rescue
e -> Left.pure(e)
end
end
```
```elixir
def get_item(id, table_name) when is_atom(table_name) do
try do
case :ets.lookup(table_name, id) do
[{^id, item}] -> Right.pure(item)
[] -> Left.pure(:not_found)
end
rescue
e -> Left.pure(e)
end
end
```
```elixir
def get_all_items(table_name) when is_atom(table_name) do
try do
items =
table_name
|> :ets.tab2list()
|> Enum.map(fn {_id, item} -> item end)
Right.pure(items)
rescue
e -> Left.pure(e)
end
end
```
```elixir
def delete_item(id, table_name) when is_atom(table_name) do
try do
:ets.delete(table_name, id)
Right.pure(id)
rescue
e -> Left.pure(e)
end
end
```
Ride Repository
```elixir
def create_table do
Store.create_table(:rides)
end
```
```elixir
def validate(%__MODULE__{} = ride) do
validators = [
&ensure_name_present/1,
&ensure_non_negative_wait_time/1,
&ensure_non_negative_min_age/1,
&ensure_non_negative_min_height/1
]
Either.validate(ride, validators)
end
```
```elixir
def save(%Ride{} = ride) do
ride
|> Ride.validate()
|> Monad.bind(&Store.insert_item(&1, :rides))
end
```
```elixir
def get(id) do
:rides
|> Store.get_item(id)
|> Monad.map(&struct(Ride, &1))
|> Either.map_left(fn _ -> :not_found end)
end
```
```elixir
def list do
:rides
|> Store.get_all_items()
|> Monad.map(fn items -> Enum.map(items, &struct(Ride, &1)) end)
|> Monad.map(&Enum.sort_by(&1, fn ride -> ride.name end))
|> Either.get_or_else([])
end
```
```elixir
def delete(%Ride{id: id}) do
Store.delete_item(id, :rides)
:ok
end
```
Let’s start by creating our table:
FunPark.Ride.Repo.create_table()
Next, we generate a couple of rides:
banana = FunPark.Ride.make("Banana Slip")
apple = FunPark.Ride.make("Apple Cart")
With the repository we can save valid rides to our store:
FunPark.Ride.Repo.save(banana)
FunPark.Ride.Repo.save(apple)
If we create an invalid ride:
bad_apple = FunPark.Ride.change(apple, %{wait_time: -1, min_age: -1})
We get Left, informing that it did not save because of a validation error:
FunPark.Ride.Repo.save(bad_apple)
We can retrieve our saved rides:
FunPark.Ride.Repo.get(banana.id)
FunPark.Ride.Repo.get(apple.id)
Or get a list of all rides in their domain order:
FunPark.Ride.Repo.list()
When we delete the Apple Cart ride:
FunPark.Ride.Repo.delete(apple)
It becomes :not_found:
FunPark.Ride.Repo.get(apple.id)
Because our delete operation is idempotent, we can delete it multiple times:
FunPark.Ride.Repo.delete(apple)
FunPark.Ride.Repo.delete(apple)
If another process deletes the entire table:
FunPark.Store.drop_table(:rides)
The Ride context can still delete:
FunPark.Ride.Repo.delete(apple)
And the caller doesn’t care that the table has been dropped—only that the ride isn’t available:
FunPark.Ride.Repo.get(apple.id)
Also, it doesn’t care why there are no rides—just that the outcome is an empty list:
FunPark.Ride.Repo.list()