Powered by AppSignal & Oban Pro

Model Outcomes with Either

chapters/chap_09_either.livemd

Model Outcomes with Either

Mix.install([
  {:fun_park,
    git: "https://github.com/JKWA/funpark_notebooks.git",
    branch: "main"
  }
])

Advanced Functional Programming with Elixir

https://www.joekoski.com/assets/images/jkelixir_small.jpg” alt=”Book cover” width=”120” /> 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()