Home Posts Post Search Tag Search

Advanced Functional Elixir - 09 - Model Outcomes with Either
Published on: 2026-04-25 Tags: elixir, Blog, Advanced Functional Programming, FunPark, Monoids, predicates, Monads, Either, Left, Right

9. Model Outcomes with Either

So now we can talk about some sort of validation. When you try to buy a plane ticket and you get these errors when you try to submit the purchase.

• Name field cannot be blank.
• Return date must be after departure date.
• Phone number is invalid.

While Maybe captures the possibility of a value. Either represents the possibility of success.

Structure of Either

First we need to use branching logic of Right and Left, one for success and one for failure.

defmodule FunPark.Monad.Either.Right do
  @enforce_keys [:right]
  defstruct [:right]
  
  def pure(value), do: %__MODULE__{right: value}
end

defmodule FunPark.Monad.Either.Left do
  @enforce_keys [:left]
  defstruct [:left]
  
  def pure(value), do: %__MODULE__{left: value}
end

This is maybe but says more.

Eq

A Right is equal if their inner values are equal, same with Lefts. A Right is never equal to a Left even if the values are the same.

Ord

Same idea here with but in this case a Left is always less than a Right.

Foldable

Either can be folded into a single result.

Filterable

Either can’t generate a new Left when a predicate fails.

Monad

The Either context supports monadic composition through map/2, bind/2, and ap/2. These work exactly as they do in Maybe, with Left taking the place of Nothing.

• map/2 transforms the Right value, leaving Left untouched.
• bind/2 sequences computations that may fail, propagating the first Left encountered.
• ap/2 applies a Right(function) to a Right(value); if either is a Left, the failure short-circuits.

Either Module

This module sets up the functions for constructing, inspecting, and adapting Either values. This will mirror Maybe but is adapted for cases where both branches carry data.


We can build/construct using right/1, left/1, or the more general pure/1. Then we can check which branch we’re working with using right?/1 and left/1.


We will also be able to use lift: lift_maybe/2, lift_predicate/3, lift_eq/1, lift_ord/1. We can also interoperate with native Elixir using from_result/1, to_result/1, from from_try/1 and to_try!/1.

Validation

Now we heard from out Ride expert, they say that people are upset that they don’t know why they are not getting a fast pass.

Ride Eligibility

We will build Right(eligible_patron) and Left(reason_for_denial).

  def lift_predicate(value, predicate, on_false)
      when is_function(predicate, 1) and is_function(on_false, 1) do
    fold_l(
      fn -> predicate.(value) end,
      fn -> right(value) end,
      fn -> left(on_false.(value)) end
    )
  end

This will allow us to have a thunked callback to a generate the Left value.


We want to now add in a service (an organization tool, grouping related behavior under a shared namespace) for the FastLane. The will ensurethat the height requirements are within our new FastLane service:

defmodule FunPark.Ride.FastLane do
  def ensure_height(%Patron{} = patron, %Ride{} = ride) do
    patron
    |> Either.lift_predicate(
      curry_r(&Ride.tall_enough?/2).(ride),
      fn p -> "#{Patron.get_name(p)} is not tall enough" end
    )
    |> Either.map_left(&ValidationError.new/1)
  end
end

So this will take the lift_predicate/3 as well as the patron and the check and then allow us to build both the left and the right.


Now let’s build the ensure_age/2 as well.

  def ensure_age(%Patron{} = patron, %Ride{} = ride) do
    patron
    |> Either.lift_predicate(
      curry_r(&Ride.old_enough?/2).(ride),
      fn p -> "#{Patron.get_name(p)} is not old enough" end
    )
    |> Either.map_left(&ValidationError.new/1)
  end

Both of these use the map_left\2 which will return the Right if no error or the Left if there is an error

  def map_left(%Left{left: error}, func), do: left(func.(error))
  def map_left(%Right{} = right, _), do: right

Okay so let’s take a second to go over the ValidationError as that is a big part of the handling of the errors.

defmodule FunPark.Errors.ValidationError do
  defstruct errors: [], __exception__: true

  @behaviour Exception

  def new(errors) when is_list(errors), do: %__MODULE__{errors: errors}

  def new(error), do: %__MODULE__{errors: [error]}

  def merge(%__MODULE__{errors: e1}, %__MODULE__{errors: e2}),
    do: %__MODULE__{errors: e1 ++ e2}

  @impl Exception
  def exception(args) when is_list(args), do: struct(__MODULE__, args)

  @impl Exception
  def exception(message) when is_binary(message),
    do: %__MODULE__{errors: [message]}

  @impl Exception
  def message(%__MODULE__{errors: errors}) do
    Enum.map_join(errors, ", ", &to_string/1)
  end
end

This is how we will deal with errors. There is 2 things here I will say then Ill give a more in-depth explanation of the functions. First ValidationError will hold a list of errors messages. Second there is no built-in validation error type in Elixir, so that is where the @impl comes in.

• new/1: Wraps a single message or a list of messages.
• merge/2: Combines two structs by joining their message lists.

• exception/1: Builds the struct from a keyword list or a single string.
• message/1: Returns a formatted string by joining the error messages.

There is a redefining of the Exception behaviour within this module.

Run it

iex(1)> alice = FunPark.Patron.make("Alice", 12, 125, ticket_tier: :vip)
%FunPark.Patron{
  id: 6274,
  name: "Alice",
  age: 12,
  height: 125,
  ticket_tier: :vip,
  fast_passes: [],
  reward_points: 0,
  likes: [],
  dislikes: []
}
iex(2)> 
nil
iex(3)> beth = FunPark.Patron.make("Beth", 16, 115)
%FunPark.Patron{
  id: 23,
  name: "Beth",
  age: 16,
  height: 115,
  ticket_tier: :basic,
  fast_passes: [],
  reward_points: 0,
  likes: [],
  dislikes: []
}
iex(4)> 
nil
iex(5)> haunted_mansion =
...(5)>   FunPark.Ride.make(
...(5)>     "Haunted Mansion",
...(5)>     min_age: 14,
...(5)>     min_height: 120
...(5)>   )
%FunPark.Ride{
  id: 6338,
  name: "Haunted Mansion",
  min_age: 14,
  min_height: 120,
  wait_time: 0,
  online: true,
  tags: []
}
iex(6)> 
nil
iex(7)> FunPark.Ride.FastLane.ensure_height(alice, haunted_mansion)
%FunPark.Monad.Either.Right{
  right: %FunPark.Patron{
    id: 6274,
    name: "Alice",
    age: 12,
    height: 125,
    ticket_tier: :vip,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  }
}
iex(8)> 
nil
iex(9)> FunPark.Ride.FastLane.ensure_age(alice, haunted_mansion)
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{errors: ["Alice is not old enough"]}
}
iex(10)> 
nil
iex(11)> FunPark.Ride.FastLane.ensure_age(beth, haunted_mansion)
%FunPark.Monad.Either.Right{
  right: %FunPark.Patron{
    id: 23,
    name: "Beth",
    age: 16,
    height: 115,
    ticket_tier: :basic,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  }
}
iex(12)> 
nil
iex(13)> FunPark.Ride.FastLane.ensure_height(beth, haunted_mansion)
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{errors: ["Beth is not tall enough"]}
}

As you can see we are able to use the FastLand that we made to give good feedback if the check fails.

Combining Eligibility

# fast_lane.ex
  def ensure_eligibility(%Patron{} = patron, %Ride{} = ride) do
    validate_height = curry_r(&ensure_height/2)

    patron
    |> ensure_age(ride)
    |> bind(validate_height.(ride))
  end

We take both of the new functions and curry them into one check

iex(14)> FunPark.Ride.FastLane.ensure_eligibility(alice, haunted_mansion)
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{errors: ["Alice is not old enough"]}
}
iex(15)> 
nil
iex(16)> FunPark.Ride.FastLane.ensure_eligibility(beth, haunted_mansion)
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{errors: ["Beth is not tall enough"]}
}
iex(17)> 
nil
iex(18)> charles = FunPark.Patron.make("Charles", 13, 115)
%FunPark.Patron{
  id: 6402,
  name: "Charles",
  age: 13,
  height: 115,
  ticket_tier: :basic,
  fast_passes: [],
  reward_points: 0,
  likes: [],
  dislikes: []
}
iex(19)> 
nil
iex(20)> FunPark.Ride.FastLane.ensure_eligibility(charles, haunted_mansion)
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{errors: ["Charles is not old enough"]}
}
iex(21)> dave = FunPark.Patron.make( "Dave", 16, 140)
%FunPark.Patron{
  id: 6466,
  name: "Dave",
  age: 16,
  height: 140,
  ticket_tier: :basic,
  fast_passes: [],
  reward_points: 0,
  likes: [],
  dislikes: []
}
iex(22)> FunPark.Ride.FastLane.ensure_eligibility(dave, haunted_mansion)
%FunPark.Monad.Either.Right{
  right: %FunPark.Patron{
    id: 6466,
    name: "Dave",
    age: 16,
    height: 140,
    ticket_tier: :basic,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  }
}

Using the new function we can check for eligibility.

Ensure a FastPass

  def ensure_fast_pass(%Patron{} = patron, %Ride{} = ride) do
    patron
    |> Either.lift_predicate(
      curry_r(&Ride.fast_pass?/2).(ride),
      fn p -> "#{Patron.get_name(p)} does not have a fast pass" end
    )
    |> Either.map_left(&ValidationError.new/1)
  end

Run it

iex(31)> FunPark.Ride.FastLane.ensure_fast_pass(dave, haunted_mansion)
%FunPark.Monad.Either.Right{
  right: %FunPark.Patron{
    id: 6466,
    name: "Dave",
    age: 16,
    height: 140,
    ticket_tier: :basic,
    fast_passes: [
      %FunPark.FastPass{
        id: 87,
        ride: %FunPark.Ride{
          id: 6338,
          name: "Haunted Mansion",
          min_age: 14,
          min_height: 120,
          wait_time: 0,
          online: true,
          tags: []
        },
        time: ~U[2025-06-01 13:00:00Z]
      }
    ],
    reward_points: 0,
    likes: [],
    dislikes: []
  }
}
iex(32)> 
nil
iex(33)> elsie = FunPark.Patron.make("Elsie", 17, 135, ticket_tier: :vip)
%FunPark.Patron{
  id: 151,
  name: "Elsie",
  age: 17,
  height: 135,
  ticket_tier: :vip,
  fast_passes: [],
  reward_points: 0,
  likes: [],
  dislikes: []
}
iex(34)> 
nil
iex(35)> FunPark.Ride.FastLane.ensure_fast_pass(elsie, haunted_mansion)
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{
    errors: ["Elsie does not have a fast pass"]
  }
}

Using the new logic we are able to give feedback on whether the Patron has a fast pass.

Ensure a FastPass or VIP Access

  def ensure_vip_or_fast_pass(%Patron{} = patron, %Ride{} = ride) do
    patron
    |> Either.lift_predicate(
      &Patron.vip?/1,
      fn p -> "#{Patron.get_name(p)} is not a VIP" end
    )
    |> Either.map_left(&ValidationError.new/1)
    |> Either.or_else(fn -> ensure_fast_pass(patron, ride) end)
  end

iex(36)> FunPark.Ride.FastLane.ensure_vip_or_fast_pass(elsie, haunted_mansion)
%FunPark.Monad.Either.Right{
  right: %FunPark.Patron{
    id: 151,
    name: "Elsie",
    age: 17,
    height: 135,
    ticket_tier: :vip,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  }
}

Ensure Fast Lane Access

  def ensure_fast_pass_lane(%Patron{} = patron, %Ride{} = ride) do
    ensure_vip_or_pass = curry_r(&ensure_vip_or_fast_pass/2)

    patron
    |> ensure_eligibility(ride)
    |> bind(ensure_vip_or_pass.(ride))
  end

Run it

iex(37)> FunPark.Ride.FastLane.ensure_fast_pass_lane(alice, haunted_mansion)
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{errors: ["Alice is not old enough"]}
}
iex(38)> FunPark.Ride.FastLane.ensure_fast_pass_lane(beth, haunted_mansion)
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{errors: ["Beth is not tall enough"]}
}
iex(39)> FunPark.Ride.FastLane.ensure_fast_pass_lane(charles, haunted_mansion)
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{errors: ["Charles is not old enough"]}
}
iex(40)> FunPark.Ride.FastLane.ensure_fast_pass_lane(dave, haunted_mansion)
%FunPark.Monad.Either.Right{
  right: %FunPark.Patron{
    id: 6466,
    name: "Dave",
    age: 16,
    height: 140,
    ticket_tier: :basic,
    fast_passes: [
      %FunPark.FastPass{
        id: 87,
        ride: %FunPark.Ride{
          id: 6338,
          name: "Haunted Mansion",
          min_age: 14,
          min_height: 120,
          wait_time: 0,
          online: true,
          tags: []
        },
        time: ~U[2025-06-01 13:00:00Z]
      }
    ],
    reward_points: 0,
    likes: [],
    dislikes: []
  }
}
iex(41)> FunPark.Ride.FastLane.ensure_fast_pass_lane(elsie, haunted_mansion)
%FunPark.Monad.Either.Right{
  right: %FunPark.Patron{
    id: 151,
    name: "Elsie",
    age: 17,
    height: 135,
    ticket_tier: :vip,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  }
}

Okay so now that we have all of this we can check for each person for each ride and then get feedback if they don’t meet any of the requirements. But now we can start to check against groups.

Ensure Groups of Patrons

  def ensure_fast_pass_lane_group(
        patrons,
        %Ride{} = ride
      )
      when is_list(patrons) do
    eligible_for_fast_lane = curry_r(&ensure_fast_pass_lane/2)

    Either.traverse(
      patrons,
      eligible_for_fast_lane.(ride)
    )
  end
iex(42)> patrons = [elsie, dave]
[
  %FunPark.Patron{
    id: 151,
    name: "Elsie",
    age: 17,
    height: 135,
    ticket_tier: :vip,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  },
  %FunPark.Patron{
    id: 6466,
    name: "Dave",
    age: 16,
    height: 140,
    ticket_tier: :basic,
    fast_passes: [
      %FunPark.FastPass{
        id: 87,
        ride: %FunPark.Ride{
          id: 6338,
          name: "Haunted Mansion",
          min_age: 14,
          min_height: 120,
          wait_time: 0,
          online: true,
          tags: []
        },
        time: ~U[2025-06-01 13:00:00Z]
      }
    ],
    reward_points: 0,
    likes: [],
    dislikes: []
  }
]
iex(43)> FunPark.Ride.FastLane.ensure_fast_pass_lane_group(patrons, haunted_mansion)
%FunPark.Monad.Either.Right{
  right: [
    %FunPark.Patron{
      id: 151,
      name: "Elsie",
      age: 17,
      height: 135,
      ticket_tier: :vip,
      fast_passes: [],
      reward_points: 0,
      likes: [],
      dislikes: []
    },
    %FunPark.Patron{
      id: 6466,
      name: "Dave",
      age: 16,
      height: 140,
      ticket_tier: :basic,
      fast_passes: [
        %FunPark.FastPass{
          id: 87,
          ride: %FunPark.Ride{
            id: 6338,
            name: "Haunted Mansion",
            min_age: 14,
            min_height: 120,
            wait_time: 0,
            online: true,
            tags: []
          },
          time: ~U[2025-06-01 13:00:00Z]
        }
      ],
      reward_points: 0,
      likes: [],
      dislikes: []
    }
  ]
}
iex(44)> patrons = [elsie, dave, charles]
[
  %FunPark.Patron{
    id: 151,
    name: "Elsie",
    age: 17,
    height: 135,
    ticket_tier: :vip,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  },
  %FunPark.Patron{
    id: 6466,
    name: "Dave",
    age: 16,
    height: 140,
    ticket_tier: :basic,
    fast_passes: [
      %FunPark.FastPass{
        id: 87,
        ride: %FunPark.Ride{
          id: 6338,
          name: "Haunted Mansion",
          min_age: 14,
          min_height: 120,
          wait_time: 0,
          online: true,
          tags: []
        },
        time: ~U[2025-06-01 13:00:00Z]
      }
    ],
    reward_points: 0,
    likes: [],
    dislikes: []
  },
  %FunPark.Patron{
    id: 6402,
    name: "Charles",
    age: 13,
    height: 115,
    ticket_tier: :basic,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  }
]
iex(45)> FunPark.Ride.FastLane.ensure_fast_pass_lane_group(patrons, haunted_mansion)
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{errors: ["Charles is not old enough"]}
}
iex(46)> patrons = [elsie, dave, charles, beth]
[
  %FunPark.Patron{
    id: 151,
    name: "Elsie",
    age: 17,
    height: 135,
    ticket_tier: :vip,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  },
  %FunPark.Patron{
    id: 6466,
    name: "Dave",
    age: 16,
    height: 140,
    ticket_tier: :basic,
    fast_passes: [
      %FunPark.FastPass{
        id: 87,
        ride: %FunPark.Ride{
          id: 6338,
          name: "Haunted Mansion",
          min_age: 14,
          min_height: 120,
          wait_time: 0,
          online: true,
          tags: []
        },
        time: ~U[2025-06-01 13:00:00Z]
      }
    ],
    reward_points: 0,
    likes: [],
    dislikes: []
  },
  %FunPark.Patron{
    id: 6402,
    name: "Charles",
    age: 13,
    height: 115,
    ticket_tier: :basic,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  },
  %FunPark.Patron{
    id: 23,
    name: "Beth",
    age: 16,
    height: 115,
    ticket_tier: :basic,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  }
]
iex(47)> FunPark.Ride.FastLane.ensure_fast_pass_lane_group(patrons, haunted_mansion)
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{errors: ["Charles is not old enough"]}
}
iex(48)> patrons = [elsie, dave, beth, charles]
[
  %FunPark.Patron{
    id: 151,
    name: "Elsie",
    age: 17,
    height: 135,
    ticket_tier: :vip,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  },
  %FunPark.Patron{
    id: 6466,
    name: "Dave",
    age: 16,
    height: 140,
    ticket_tier: :basic,
    fast_passes: [
      %FunPark.FastPass{
        id: 87,
        ride: %FunPark.Ride{
          id: 6338,
          name: "Haunted Mansion",
          min_age: 14,
          min_height: 120,
          wait_time: 0,
          online: true,
          tags: []
        },
        time: ~U[2025-06-01 13:00:00Z]
      }
    ],
    reward_points: 0,
    likes: [],
    dislikes: []
  },
  %FunPark.Patron{
    id: 23,
    name: "Beth",
    age: 16,
    height: 115,
    ticket_tier: :basic,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  },
  %FunPark.Patron{
    id: 6402,
    name: "Charles",
    age: 13,
    height: 115,
    ticket_tier: :basic,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  }
]
iex(49)> FunPark.Ride.FastLane.ensure_fast_pass_lane_group(patrons, haunted_mansion)
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{errors: ["Beth is not tall enough"]}
}

Okay so we are using the final Monad to test against a group of Patrons

From Bind to Combine

We have a new concern. We need a way to combine all the errors so that we see every error, if Beth can’t go and Alice can’t go Beth won’t be able to see the issues until they get to the front.

  def traverse_a([], _func), do: right([])

  def traverse_a(list, func) when is_list(list) and is_function(func, 1) do
    fold_l(list, right([]), fn item, acc_result ->
      case {func.(item), acc_result} do
        {%Right{right: value}, %Right{right: acc}} ->
          right([value | acc])

        {%Left{left: new}, %Left{left: existing}} ->
          left(append(existing, coerce(new)))

        {%Right{}, %Left{left: existing}} ->
          left(existing)

        {%Left{left: err}, %Right{}} ->
          left(coerce(err))
      end
    end)
    |> map(&:lists.reverse/1)
  end

If all items pass then we get a Right if anyone of them fail we get a Left. With this we need to append all the results into one list so we need to implement an Appendable.

defprotocol FunPark.Appendable do
  @fallback_to_any true

  def coerce(term)

  def append(accumulator, coerced)
end

defimpl FunPark.Appendable, for: Any do
  def coerce(value) when is_list(value), do: value
  def coerce(value), do: [value]

  def append(acc, value), do: coerce(acc) ++ coerce(value)
end

# validation_error.ex
defimpl FunPark.Appendable, for: FunPark.Errors.ValidationError do
  alias FunPark.Errors.ValidationError

  def coerce(%ValidationError{errors: e}), do: ValidationError.new(e)

  def append(%ValidationError{} = acc, %ValidationError{} = value) do
    ValidationError.merge(acc, value)
  end
end

This will allow us to take any number of errors and append them to the list as we get them.

• coerce/1 calls new/1, ensuring the errors field is always a list.
• append/2 combines two ValidationError structs, using merge/2.

Run It

iex(50)> alice = FunPark.Patron.make("Alice", 12, 125, ticket_tier: :vip)
%FunPark.Patron{
  id: 215,
  name: "Alice",
  age: 12,
  height: 125,
  ticket_tier: :vip,
  fast_passes: [],
  reward_points: 0,
  likes: [],
  dislikes: []
}
iex(51)> beth = FunPark.Patron.make("Beth", 16, 115)
%FunPark.Patron{
  id: 279,
  name: "Beth",
  age: 16,
  height: 115,
  ticket_tier: :basic,
  fast_passes: [],
  reward_points: 0,
  likes: [],
  dislikes: []
}
iex(52)> charles = FunPark.Patron.make("Charles", 13, 115)
%FunPark.Patron{
  id: 6658,
  name: "Charles",
  age: 13,
  height: 115,
  ticket_tier: :basic,
  fast_passes: [],
  reward_points: 0,
  likes: [],
  dislikes: []
}
iex(53)> dave = FunPark.Patron.make("Dave", 16, 140)
%FunPark.Patron{
  id: 6722,
  name: "Dave",
  age: 16,
  height: 140,
  ticket_tier: :basic,
  fast_passes: [],
  reward_points: 0,
  likes: [],
  dislikes: []
}
iex(54)> elsie = FunPark.Patron.make("Elsie", 17, 135, ticket_tier: :vip)
%FunPark.Patron{
  id: 6786,
  name: "Elsie",
  age: 17,
  height: 135,
  ticket_tier: :vip,
  fast_passes: [],
  reward_points: 0,
  likes: [],
  dislikes: []
}
iex(55)> haunted_mansion = FunPark.Ride.make("Haunted Mansion", min_age: 14, min_height: 120)
%FunPark.Ride{
  id: 6850,
  name: "Haunted Mansion",
  min_age: 14,
  min_height: 120,
  wait_time: 0,
  online: true,
  tags: []
}
iex(56)> valid_height = FunPark.Utils.curry_r(&FunPark.Ride.FastLane.ensure_height/2)
#Function<3.64560675/1 in FunPark.Utils.curry_r/3>
iex(57)> valid_age = FunPark.Utils.curry_r(&FunPark.Ride.FastLane.ensure_age/2)
#Function<3.64560675/1 in FunPark.Utils.curry_r/3>
iex(58)> validators = [valid_height.(haunted_mansion), valid_age.(haunted_mansion)]
[#Function<2.64560675/1 in FunPark.Utils.curry_r/3>,
 #Function<2.64560675/1 in FunPark.Utils.curry_r/3>]
iex(59)> FunPark.Monad.Either.traverse_a(validators, & &1.(alice))
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{errors: ["Alice is not old enough"]}
}
iex(60)> FunPark.Monad.Either.traverse_a(validators, & &1.(beth))
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{errors: ["Beth is not tall enough"]}
}
iex(61)> FunPark.Monad.Either.traverse_a(validators, & &1.(charles))
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{
    errors: ["Charles is not tall enough", "Charles is not old enough"]
  }
}
iex(62)> FunPark.Monad.Either.traverse_a(validators, & &1.(dave))
%FunPark.Monad.Either.Right{
  right: [
    %FunPark.Patron{
      id: 6722,
      name: "Dave",
      age: 16,
      height: 140,
      ticket_tier: :basic,
      fast_passes: [],
      reward_points: 0,
      likes: [],
      dislikes: []
    },
    %FunPark.Patron{
      id: 6722,
      name: "Dave",
      age: 16,
      height: 140,
      ticket_tier: :basic,
      fast_passes: [],
      reward_points: 0,
      likes: [],
      dislikes: []
    }
  ]
}
iex(63)> FunPark.Monad.Either.validate(dave, validators)
%FunPark.Monad.Either.Right{
  right: %FunPark.Patron{
    id: 6722,
    name: "Dave",
    age: 16,
    height: 140,
    ticket_tier: :basic,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  }
}
iex(64)> FunPark.Monad.Either.validate(charles, validators)
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{
    errors: ["Charles is not tall enough", "Charles is not old enough"]
  }
}

We now build the Patrons, Rides, and FastPass then we check against the different validators. Once we traverse_a we can build a list of errors or the full list of rides that pass the tests. Again if any of them fail we get a Left. This is good but we get a Right lists of Daves at the end. We want to just show a single Dave if we pass.

# either.ex
  def validate(value, validators) when is_list(validators) do
    traverse_a(validators, fn validator -> validator.(value) end)
    |> map(fn _ -> value end)
  end

Now let’s run that.

iex(65)> FunPark.Monad.Either.validate(dave, validators)
%FunPark.Monad.Either.Right{
  right: %FunPark.Patron{
    id: 6722,
    name: "Dave",
    age: 16,
    height: 140,
    ticket_tier: :basic,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  }
}
iex(66)> FunPark.Monad.Either.validate(charles, validators)
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{
    errors: ["Charles is not tall enough", "Charles is not old enough"]
  }
}

Okay so now we can just get back the list of the errors, or the Patron. We can add more to the FastLane and then add in the functionality to the Ride

# fast_lane.ex
  def validate_eligibility(%Patron{} = patron, %Ride{} = ride) do
    validate_height = curry_r(&ensure_height/2)
    validate_age = curry_r(&ensure_age/2)

    patron
    |> Either.validate([validate_height.(ride), validate_age.(ride)])
  end

  def validate_fast_pass_lane(%Patron{} = patron, %Ride{} = ride) do
    validate_eligibility = curry(&validate_eligibility/2)
    validate_vip_or_pass = curry(&ensure_vip_or_fast_pass/2)

    Either.validate(
      ride,
      [
        validate_eligibility.(patron),
        validate_vip_or_pass.(patron),
        &Ride.ensure_online/1
      ]
    )
    |> map(fn _ -> patron end)
  end

# ride.ex
  def ensure_online(%__MODULE__{} = ride) do
    Either.lift_predicate(
      ride,
      &online?/1,
      fn r -> "#{r.name} is offline" end
    )
    |> Either.map_left(&ValidationError.new/1)
  end

Run It

iex(67)> FunPark.Ride.FastLane.validate_fast_pass_lane(alice, haunted_mansion)
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{errors: ["Alice is not old enough"]}
}
iex(68)> FunPark.Ride.FastLane.validate_fast_pass_lane(beth, haunted_mansion)
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{
    errors: ["Beth is not tall enough", "Beth does not have a fast pass"]
  }
}
iex(69)> FunPark.Ride.FastLane.validate_fast_pass_lane(charles, haunted_mansion)
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{
    errors: ["Charles is not tall enough", "Charles is not old enough",
     "Charles does not have a fast pass"]
  }
}
iex(70)> FunPark.Ride.FastLane.validate_fast_pass_lane(dave, haunted_mansion)
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{
    errors: ["Dave does not have a fast pass"]
  }
}
iex(71)> datetime = DateTime.new!(~D[2025-06-01], ~T[13:00:00])
~U[2025-06-01 13:00:00Z]
iex(72)> fast_pass = FunPark.FastPass.make(haunted_mansion, datetime)
%FunPark.FastPass{
  id: 7106,
  ride: %FunPark.Ride{
    id: 6850,
    name: "Haunted Mansion",
    min_age: 14,
    min_height: 120,
    wait_time: 0,
    online: true,
    tags: []
  },
  time: ~U[2025-06-01 13:00:00Z]
}
iex(73)> dave = FunPark.Patron.add_fast_pass(dave, fast_pass)
%FunPark.Patron{
  id: 6722,
  name: "Dave",
  age: 16,
  height: 140,
  ticket_tier: :basic,
  fast_passes: [
    %FunPark.FastPass{
      id: 7106,
      ride: %FunPark.Ride{
        id: 6850,
        name: "Haunted Mansion",
        min_age: 14,
        min_height: 120,
        wait_time: 0,
        online: true,
        tags: []
      },
      time: ~U[2025-06-01 13:00:00Z]
    }
  ],
  reward_points: 0,
  likes: [],
  dislikes: []
}
iex(74)> FunPark.Ride.FastLane.validate_fast_pass_lane(dave, haunted_mansion)
%FunPark.Monad.Either.Right{
  right: %FunPark.Patron{
    id: 6722,
    name: "Dave",
    age: 16,
    height: 140,
    ticket_tier: :basic,
    fast_passes: [
      %FunPark.FastPass{
        id: 7106,
        ride: %FunPark.Ride{
          id: 6850,
          name: "Haunted Mansion",
          min_age: 14,
          min_height: 120,
          wait_time: 0,
          online: true,
          tags: []
        },
        time: ~U[2025-06-01 13:00:00Z]
      }
    ],
    reward_points: 0,
    likes: [],
    dislikes: []
  }
}
iex(75)> FunPark.Ride.FastLane.validate_fast_pass_lane(elsie, haunted_mansion)
%FunPark.Monad.Either.Right{
  right: %FunPark.Patron{
    id: 6786,
    name: "Elsie",
    age: 17,
    height: 135,
    ticket_tier: :vip,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  }
}
iex(76)> haunted_mansion = FunPark.Ride.change(haunted_mansion, %{online: false})
%FunPark.Ride{
  id: 6850,
  name: "Haunted Mansion",
  min_age: 14,
  min_height: 120,
  wait_time: 0,
  online: false,
  tags: []
}
iex(77)> FunPark.Ride.FastLane.validate_fast_pass_lane(elsie, haunted_mansion)
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{errors: ["Haunted Mansion is offline"]}
}

We built a new error handler for the Ride and then added all the tests to the validate_fast_pass_lane/2 so that we can then check against all the tests.

Don’t Look, Just Fly

Okay so now we want to test against an entire group and then pass or fail the entire group. You should give reasons if they fail.

  def validate_fast_pass_lane_group(
        patrons,
        %Ride{} = ride
      )
      when is_list(patrons) do
    validate_fast_lane = curry_r(&validate_fast_pass_lane/2)

    patrons
    |> Either.traverse_a(validate_fast_lane.(ride))
  end

Run It

iex(78)> haunted_mansion = FunPark.Ride.change(haunted_mansion, %{online: true})
%FunPark.Ride{
  id: 6850,
  name: "Haunted Mansion",
  min_age: 14,
  min_height: 120,
  wait_time: 0,
  online: true,
  tags: []
}
iex(79)> patrons = [alice, beth, charles, dave, elsie]
[
  %FunPark.Patron{
    id: 215,
    name: "Alice",
    age: 12,
    height: 125,
    ticket_tier: :vip,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  },
  %FunPark.Patron{
    id: 279,
    name: "Beth",
    age: 16,
    height: 115,
    ticket_tier: :basic,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  },
  %FunPark.Patron{
    id: 6658,
    name: "Charles",
    age: 13,
    height: 115,
    ticket_tier: :basic,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  },
  %FunPark.Patron{
    id: 6722,
    name: "Dave",
    age: 16,
    height: 140,
    ticket_tier: :basic,
    fast_passes: [
      %FunPark.FastPass{
        id: 7106,
        ride: %FunPark.Ride{
          id: 6850,
          name: "Haunted Mansion",
          min_age: 14,
          min_height: 120,
          wait_time: 0,
          online: true,
          tags: []
        },
        time: ~U[2025-06-01 13:00:00Z]
      }
    ],
    reward_points: 0,
    likes: [],
    dislikes: []
  },
  %FunPark.Patron{
    id: 6786,
    name: "Elsie",
    age: 17,
    height: 135,
    ticket_tier: :vip,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  }
]
iex(80)> FunPark.Ride.FastLane.validate_fast_pass_lane_group(patrons, haunted_mansion)
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{
    errors: ["Alice is not old enough", "Beth is not tall enough",
     "Beth does not have a fast pass", "Charles is not tall enough",
     "Charles is not old enough", "Charles does not have a fast pass"]
  }
}
iex(81)> patrons = [dave, elsie]
[
  %FunPark.Patron{
    id: 6722,
    name: "Dave",
    age: 16,
    height: 140,
    ticket_tier: :basic,
    fast_passes: [
      %FunPark.FastPass{
        id: 7106,
        ride: %FunPark.Ride{
          id: 6850,
          name: "Haunted Mansion",
          min_age: 14,
          min_height: 120,
          wait_time: 0,
          online: true,
          tags: []
        },
        time: ~U[2025-06-01 13:00:00Z]
      }
    ],
    reward_points: 0,
    likes: [],
    dislikes: []
  },
  %FunPark.Patron{
    id: 6786,
    name: "Elsie",
    age: 17,
    height: 135,
    ticket_tier: :vip,
    fast_passes: [],
    reward_points: 0,
    likes: [],
    dislikes: []
  }
]
iex(82)> FunPark.Ride.FastLane.validate_fast_pass_lane_group(patrons, haunted_mansion)
%FunPark.Monad.Either.Right{
  right: [
    %FunPark.Patron{
      id: 6722,
      name: "Dave",
      age: 16,
      height: 140,
      ticket_tier: :basic,
      fast_passes: [
        %FunPark.FastPass{
          id: 7106,
          ride: %FunPark.Ride{
            id: 6850,
            name: "Haunted Mansion",
            min_age: 14,
            min_height: 120,
            wait_time: 0,
            online: true,
            tags: []
          },
          time: ~U[2025-06-01 13:00:00Z]
        }
      ],
      reward_points: 0,
      likes: [],
      dislikes: []
    },
    %FunPark.Patron{
      id: 6786,
      name: "Elsie",
      age: 17,
      height: 135,
      ticket_tier: :vip,
      fast_passes: [],
      reward_points: 0,
      likes: [],
      dislikes: []
    }
  ]
}

Now that we have a list of in-eligible Patrons and the function for it we now get a list of reasons why the group fails.

Make Errors Explicit

The runtime can and should be able to use the errors to get a better view of the system and why it failed.

A Store for FunPark

Let’s use ETS to create a table for the Errors


Create Table


defmodule FunPark.Store do
  import FunPark.Monad
  alias FunPark.Monad.Either

  def create_table(table) when is_atom(table) do
    Either.from_try(fn ->
      :ets.new(table, [:named_table, :set, :public])
    end)
  end


  def drop_table(table) when is_atom(table) do
    Either.from_try(fn ->
      :ets.delete(table)
    end)
    |> map(fn _ -> table end)
  end


  def insert_item(table, %{id: id} = item) when is_atom(table) do
    Either.from_try(fn ->
      :ets.insert(table, {id, Map.from_struct(item)})
    end)
    |> map(fn _ -> item end)
  end


  def get_item(table, id) when is_atom(table) do
    Either.from_try(fn ->
      :ets.lookup(table, id)
    end)
    |> bind(fn
      [{_id, item}] -> Either.pure(item)
      [] -> Either.left(:not_found)
    end)
  end


  def get_all_items(table) when is_atom(table) do
    Either.from_try(fn ->
      :ets.tab2list(table)
    end)
    |> map(fn items ->
      Enum.map(items, fn {_, item} -> item end)
    end)
  end


  def delete_item(table, id) when is_atom(table) do
    Either.from_try(fn ->
      :ets.delete(table, id)
    end)
    |> map(fn _ -> id end)
  end

end

Ride Repository

defmodule FunPark.Ride.Repo do
  import FunPark.Monad
  import FunPark.Utils, only: [curry: 1]

  alias FunPark.Monad.Either
  alias FunPark.List
  alias FunPark.Ride
  alias FunPark.Store

  @table_name :rides

  def create_table do
    Store.create_table(@table_name)
  end


  def save(%Ride{} = ride) do
    insert_ride = curry(&Store.insert_item/2)

    ride
    |> Ride.validate()
    |> bind(insert_ride.(@table_name))
  end


  def get(id) when is_integer(id) do
    Store.get_item(@table_name, id)
    |> map(fn data -> struct(Ride, data) end)
    |> Either.map_left(fn _ -> :not_found end)
  end


  def list() do
    Store.get_all_items(@table_name)
    |> map(fn items ->
      items
      |> Enum.map(fn item -> struct(Ride, item) end)
      |> List.sort()
    end)
    |> Either.get_or_else([])
  end


  def delete(%Ride{id: id}) do
    Store.delete_item(@table_name, id)
    |> Either.get_or_else(:ok)
  end

end

# ride.ex (we need to validate a ride first)
  def validate(%__MODULE__{} = ride) do
    Either.validate(
      ride,
      [
        &ensure_min_age/1,
        &ensure_min_height/1,
        &ensure_wait_time/1,
        &ensure_name/1
      ]
    )
  end

Run It

iex(83)> FunPark.Ride.Repo.create_table()
%FunPark.Monad.Either.Right{right: :rides}
iex(84)> banana = FunPark.Ride.make("Banana Slip")
%FunPark.Ride{
  id: 535,
  name: "Banana Slip",
  min_age: 0,
  min_height: 0,
  wait_time: 0,
  online: true,
  tags: []
}
iex(85)> apple = FunPark.Ride.make("Apple Cart")
%FunPark.Ride{
  id: 599,
  name: "Apple Cart",
  min_age: 0,
  min_height: 0,
  wait_time: 0,
  online: true,
  tags: []
}
iex(86)> FunPark.Ride.Repo.save(banana)
%FunPark.Monad.Either.Right{
  right: %FunPark.Ride{
    id: 535,
    name: "Banana Slip",
    min_age: 0,
    min_height: 0,
    wait_time: 0,
    online: true,
    tags: []
  }
}
iex(87)> FunPark.Ride.Repo.save(apple)
%FunPark.Monad.Either.Right{
  right: %FunPark.Ride{
    id: 599,
    name: "Apple Cart",
    min_age: 0,
    min_height: 0,
    wait_time: 0,
    online: true,
    tags: []
  }
}
iex(88)> bad_apple = FunPark.Ride.change(apple, %{wait_time: -1, min_age: -1})
%FunPark.Ride{
  id: 599,
  name: "Apple Cart",
  min_age: -1,
  min_height: 0,
  wait_time: -1,
  online: true,
  tags: []
}
iex(89)> FunPark.Ride.Repo.save(bad_apple)
%FunPark.Monad.Either.Left{
  left: %FunPark.Errors.ValidationError{
    errors: ["Apple Cart: min age must be non negative",
     "Apple Cart: wait time must be non negative"]
  }
}
iex(90)> FunPark.Ride.Repo.get(banana.id)
%FunPark.Monad.Either.Right{
  right: %FunPark.Ride{
    id: 535,
    name: "Banana Slip",
    min_age: 0,
    min_height: 0,
    wait_time: 0,
    online: true,
    tags: []
  }
}
iex(91)> FunPark.Ride.Repo.get(apple.id)
%FunPark.Monad.Either.Right{
  right: %FunPark.Ride{
    id: 599,
    name: "Apple Cart",
    min_age: 0,
    min_height: 0,
    wait_time: 0,
    online: true,
    tags: []
  }
}
iex(92)> FunPark.Ride.Repo.list()
[
  %FunPark.Ride{
    id: 599,
    name: "Apple Cart",
    min_age: 0,
    min_height: 0,
    wait_time: 0,
    online: true,
    tags: []
  },
  %FunPark.Ride{
    id: 535,
    name: "Banana Slip",
    min_age: 0,
    min_height: 0,
    wait_time: 0,
    online: true,
    tags: []
  }
]
iex(93)> FunPark.Ride.Repo.delete(apple)
599
iex(94)> FunPark.Ride.Repo.get(apple.id)
%FunPark.Monad.Either.Left{left: :not_found}
iex(95)> FunPark.Ride.Repo.delete(apple)
599
iex(96)> FunPark.Ride.Repo.delete(apple)
599
iex(97)> FunPark.Store.drop_table(:rides)
%FunPark.Monad.Either.Right{right: :rides}
iex(98)> FunPark.Ride.Repo.delete(apple)
:ok
iex(99)> FunPark.Ride.Repo.get(apple.id)
%FunPark.Monad.Either.Left{left: :not_found}
iex(100)> FunPark.Ride.Repo.list()
[]

We now have a table that will keep track of the rides and will even validate them before we add them to the list. We then can delete them from the table.

What You’ve Learned

This is a great way to deal with errors and to then populate the lists or errors that we find. Keep this in mind when you are building an error condition, and feedback. It might feel like a lot of work but you will get a lot of functionality from the work you put in.