Run this notebook

Use Livebook to open this notebook and explore new ideas.

It is easy to get started, on your machine or the cloud.

Click below to open and run it in your Livebook at .

(or change your Livebook location)

# Model Outcomes with Either ```elixir Mix.install([ {:fun_park, git: "https://github.com/JKWA/funpark_notebooks.git", branch: "main" } ]) ``` ## Advanced Functional Programming with Elixir | | | | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | <img src="https://www.joekoski.com/assets/images/jkelixir_small.jpg" alt="Book cover" width="120"> | **Interactive Examples from Chapter 9**<br/>[Advanced Functional Programming with Elixir](https://pragprog.com/titles/jkelixir/advanced-functional-programming-with-elixir). | ## Structure of Either ````markdown ```elixir defmodule FunPark.Monad.Either.Right do defstruct [:right] def pure(value), do: %__MODULE__{right: value} end ``` ```` ````markdown ```elixir defmodule FunPark.Monad.Either.Left do defstruct [:left] def pure(value), do: %__MODULE__{left: value} end ``` ```` ## Validation ````markdown ```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 ``` ```` ````markdown ```elixir def ensure_height(patron, ride) do Either.lift_predicate( Ride.tall_enough?(ride), &ValidationError.new("#{&1.name} is not tall enough"), patron ) end ``` ```` ````markdown ```elixir def ensure_age(patron, ride) do Either.lift_predicate( Ride.old_enough?(ride), &ValidationError.new("#{&1.name} is not old enough"), patron ) end ``` ```` ````markdown ```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: ```elixir 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: ```elixir FunPark.Ride.FastLane.ensure_height(alice, haunted_mansion) ``` ```elixir FunPark.Ride.FastLane.ensure_age(alice, haunted_mansion) ``` When we check Beth, although she meets the age requirement, she is too short: ```elixir FunPark.Ride.FastLane.ensure_age(beth, haunted_mansion) ``` ```elixir FunPark.Ride.FastLane.ensure_height(beth, haunted_mansion) ``` ### Combining Eligibility ````markdown ```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: ```elixir FunPark.Ride.FastLane.ensure_eligibility(alice, haunted_mansion) ``` ```elixir FunPark.Ride.FastLane.ensure_eligibility(beth, haunted_mansion) ``` Let's look at Charles, who is both too young and too short: ```elixir charles = FunPark.Patron.make("Charles", 13, 115) ``` ```elixir FunPark.Ride.FastLane.ensure_eligibility(charles, haunted_mansion) ``` Now let's check Dave: ```elixir dave = FunPark.Patron.make("Dave", 16, 140) ``` Dave meets the ride's eligibility criteria: ```elixir FunPark.Ride.FastLane.ensure_eligibility(dave, haunted_mansion) ``` ### Ensure a Fast Pass ````markdown ```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: ```elixir FunPark.Ride.FastLane.ensure_fast_pass(dave, haunted_mansion) ``` Let's give him one: ```elixir 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: ```elixir 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: ```elixir elsie = FunPark.Patron.make("Elsie", 17, 135, ticket_tier: :vip) ``` ```elixir FunPark.Ride.FastLane.ensure_fast_pass(elsie, haunted_mansion) ``` ### Ensure a Fast Pass or VIP Access ````markdown ```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: ```elixir FunPark.Ride.FastLane.ensure_vip_or_fast_pass(elsie, haunted_mansion) ``` ### Ensure Fast Lane Access ````markdown ```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: ```elixir FunPark.Ride.FastLane.ensure_fast_pass_lane(alice, haunted_mansion) ``` ```elixir FunPark.Ride.FastLane.ensure_fast_pass_lane(beth, haunted_mansion) ``` ```elixir FunPark.Ride.FastLane.ensure_fast_pass_lane(charles, haunted_mansion) ``` ```elixir FunPark.Ride.FastLane.ensure_fast_pass_lane(dave, haunted_mansion) ``` ```elixir FunPark.Ride.FastLane.ensure_fast_pass_lane(elsie, haunted_mansion) ``` ### Ensure Groups of Patrons ````markdown ```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: ```elixir patrons = [elsie, dave] FunPark.Ride.FastLane.ensure_fast_pass_lane_group( patrons, haunted_mansion ) ``` But not with Charles: ```elixir 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: ```elixir 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: ```elixir patrons = [elsie, dave, beth, charles] FunPark.Ride.FastLane.ensure_fast_pass_lane_group( patrons, haunted_mansion ) ``` ## From Bind to Combine ````markdown ```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 ``` ```` ````markdown ```elixir defprotocol FunPark.Appendable do def coerce(value) def append(left, right) end ``` ```` ````markdown ```elixir defimpl FunPark.Appendable, for: Any do def coerce(value), do: [value] def append(left, right), do: left ++ right end ``` ```` ````markdown ```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: ```elixir 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: ```elixir 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: ```elixir 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: ```elixir FunPark.Monad.Either.traverse_a(validators, & &1.(alice)) ``` Let's apply them to Beth, who is too short: ```elixir FunPark.Monad.Either.traverse_a(validators, & &1.(beth)) ``` And Charles, who is both too short and too young: ```elixir FunPark.Monad.Either.traverse_a(validators, & &1.(charles)) ``` Let's check Dave, who is eligible: ```elixir FunPark.Monad.Either.traverse_a(validators, & &1.(dave)) ``` ````markdown ```elixir def validate(value, validators) do validators |> traverse_a(& &1.(value)) |> Monad.map(fn _ -> value end) end ``` ```` Here, Dave is eligible: ```elixir FunPark.Monad.Either.validate(dave, validators) ``` And again, Charles is not: ```elixir FunPark.Monad.Either.validate(charles, validators) ``` ````markdown ```elixir def validate_eligibility(patron, ride) do validators = [ &ensure_height(&1, ride), &ensure_age(&1, ride) ] Either.validate(patron, validators) end ``` ```` ````markdown ```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) ``` ```` ````markdown ```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: ```elixir FunPark.Ride.FastLane.validate_fast_pass_lane(alice, haunted_mansion) ``` Beth is too short and lacks a fast pass: ```elixir 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: ```elixir FunPark.Ride.FastLane.validate_fast_pass_lane(charles, haunted_mansion) ``` Dave is tall enough and old enough—but doesn't have a fast pass: ```elixir FunPark.Ride.FastLane.validate_fast_pass_lane(dave, haunted_mansion) ``` Let's give him one: ```elixir 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: ```elixir FunPark.Ride.FastLane.validate_fast_pass_lane(dave, haunted_mansion) ``` And finally, Elsie—who is eligible and a VIP—can enter the fast lane: ```elixir FunPark.Ride.FastLane.validate_fast_pass_lane(elsie, haunted_mansion) ``` But if we take Haunted Mansion offline: ```elixir haunted_mansion = FunPark.Ride.change( haunted_mansion, %{online: false} ) ``` She can no longer enter the fast lane: ```elixir FunPark.Ride.FastLane.validate_fast_pass_lane(elsie, haunted_mansion) ``` ````markdown ```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: ```elixir 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: ```elixir patrons = [dave, elsie] FunPark.Ride.FastLane.validate_fast_pass_lane_group( patrons, haunted_mansion ) ``` ## Make Errors Explicit ### A Store for FunPark ````markdown ```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 ``` ```` ````markdown ```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 ``` ```` ````markdown ```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 ``` ```` ````markdown ```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 ``` ```` ````markdown ```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 ``` ```` ````markdown ```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 ````markdown ```elixir def create_table do Store.create_table(:rides) end ``` ```` ````markdown ```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 ``` ```` ````markdown ```elixir def save(%Ride{} = ride) do ride |> Ride.validate() |> Monad.bind(&Store.insert_item(&1, :rides)) end ``` ```` ````markdown ```elixir def get(id) do :rides |> Store.get_item(id) |> Monad.map(&struct(Ride, &1)) |> Either.map_left(fn _ -> :not_found end) end ``` ```` ````markdown ```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 ``` ```` ````markdown ```elixir def delete(%Ride{id: id}) do Store.delete_item(id, :rides) :ok end ``` ```` Let's start by creating our table: ```elixir FunPark.Ride.Repo.create_table() ``` Next, we generate a couple of rides: ```elixir banana = FunPark.Ride.make("Banana Slip") apple = FunPark.Ride.make("Apple Cart") ``` With the repository we can save valid rides to our store: ```elixir FunPark.Ride.Repo.save(banana) ``` ```elixir FunPark.Ride.Repo.save(apple) ``` If we create an invalid ride: ```elixir 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: ```elixir FunPark.Ride.Repo.save(bad_apple) ``` We can retrieve our saved rides: ```elixir FunPark.Ride.Repo.get(banana.id) ``` ```elixir FunPark.Ride.Repo.get(apple.id) ``` Or get a list of all rides in their domain order: ```elixir FunPark.Ride.Repo.list() ``` When we delete the Apple Cart ride: ```elixir FunPark.Ride.Repo.delete(apple) ``` It becomes :not_found: ```elixir FunPark.Ride.Repo.get(apple.id) ``` Because our delete operation is idempotent, we can delete it multiple times: ```elixir FunPark.Ride.Repo.delete(apple) ``` ```elixir FunPark.Ride.Repo.delete(apple) ``` If another process deletes the entire table: ```elixir FunPark.Store.drop_table(:rides) ``` The Ride context can still delete: ```elixir FunPark.Ride.Repo.delete(apple) ``` And the caller doesn't care that the table has been dropped—only that the ride isn't available: ```elixir FunPark.Ride.Repo.get(apple.id) ``` Also, it doesn't care why there are no rides—just that the outcome is an empty list: ```elixir FunPark.Ride.Repo.list() ```
See source

Have you already installed Livebook?

If you already installed Livebook, you can configure the default Livebook location where you want to open notebooks.
Livebook up Checking status We can't reach this Livebook (but we saved your preference anyway)
Run notebook

Not yet? Install Livebook in just a minute

Livebook is open source, free, and ready to run anywhere.

Run on your machine

with Livebook Desktop

Run in the cloud

on select platforms

To run on Linux, Docker, embedded devices, or Elixir’s Mix, check our README.

PLATINUM SPONSORS
SPONSORS
Code navigation with go to definition of modules and functions Read More ×