Powered by AppSignal & Oban Pro

Funx.Monad.Either.Dsl

livebooks/monad/either/either_dsl.livemd

Funx.Monad.Either.Dsl

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

Overview

The Funx.Monad.Either.Dsl module provides a macro-based DSL for writing declarative pipelines that may fail. Instead of manually chaining bind, map, and other Either operations, you can express your logic in a clean, readable way.

The DSL automatically:

  • Lifts input values into the Either context
  • Handles errors by short-circuiting on the first failure
  • Provides compile-time warnings for common mistakes
  • Supports multiple output formats (Either, tuple, or raise)

Setup

use Funx.Monad.Either

Basic Usage

Simple Pipeline with bind

The bind operation is used when your function returns an Either or result tuple.

# Helper functions for managing superheroes
defmodule HeroRegistry do
  use Funx.Monad.Either

  def parse_hero_id(input) do
    case Integer.parse(input) do
      {id, ""} when id > 0 -> right(id)
      _ -> left("Invalid hero ID")
    end
  end

  def fetch_hero(id) do
    # Simulate database lookup
    heroes = %{
      1 => %{id: 1, name: "Lightning Bolt", power: "super speed", active: true, level: 10},
      2 => %{id: 2, name: "Iron Shield", power: "invulnerability", active: false, level: 8},
      3 => %{id: 3, name: "Shadow Phantom", power: "invisibility", active: true, level: 7}
    }

    case Map.get(heroes, id) do
      nil -> {:error, "Hero not found"}
      hero -> {:ok, hero}
    end
  end

  def check_active(hero) do
    if hero.active do
      right(hero)
    else
      left("Hero is retired")
    end
  end
end
# Using the DSL - each step returns Either
either "1" do
  bind HeroRegistry.parse_hero_id()
  bind HeroRegistry.fetch_hero()
  bind HeroRegistry.check_active()
end
# When any step fails, the pipeline short-circuits (hero not found)
either "999" do
  bind HeroRegistry.parse_hero_id()
  bind HeroRegistry.fetch_hero()
  bind HeroRegistry.check_active()
end
# Invalid input fails at the first step
either "not-a-number" do
  bind HeroRegistry.parse_hero_id()
  bind HeroRegistry.fetch_hero()
  bind HeroRegistry.check_active()
end
# Retired hero fails the active check
either "2" do
  bind HeroRegistry.parse_hero_id()
  bind HeroRegistry.fetch_hero()
  bind HeroRegistry.check_active()
end

Using map for Transformations

The map operation transforms a value without changing the Either context. Use it when your function returns a plain value, not an Either.

# Transform hero to display format
either "1" do
  bind HeroRegistry.parse_hero_id()
  bind HeroRegistry.fetch_hero()
  bind HeroRegistry.check_active()
  map fn hero -> "#{hero.name} (#{hero.power})" end
end

Using tap for Side Effects

The tap operation executes a side-effect function on a Right value and returns the original Either unchanged. This is useful for debugging, logging, or performing side effects without changing the value.

If the Either is Left, the tap function is not called.

# Side effect on Right value
either "1" do
  bind HeroRegistry.parse_hero_id()
  tap fn id -> IO.inspect(id, label: "hero id") end
  bind HeroRegistry.fetch_hero()
  tap fn hero -> IO.inspect(hero.name, label: "hero name") end
  bind HeroRegistry.check_active()
  map fn hero -> "#{hero.name} (#{hero.power})" end
end
# No side effect on Left
either "invalid" do
  bind HeroRegistry.parse_hero_id()
  tap fn id -> IO.inspect(id, label: "should not see this") end
  bind HeroRegistry.fetch_hero()
end

Inline Anonymous Functions

You can use inline anonymous functions at any step:

# Parse and validate hero ID inline
either "1" do
  bind fn id_str ->
    case Integer.parse(id_str) do
      {id, ""} when id > 0 -> right(id)
      _ -> left("Invalid hero ID")
    end
  end
  bind HeroRegistry.fetch_hero()
  map fn hero -> hero.name end
end

Using Modules with tap, map_left, and filter_or_else

Just like bind and map, you can use modules that implement the run/3 callback with other DSL operations like tap, map_left, and filter_or_else. This is particularly useful for creating reusable logging, error transformation, and validation logic.

Note: Due to Livebook’s module compilation behavior, the module-based examples below are commented out. These patterns work correctly in compiled Elixir code (tests, applications) but may not work reliably when modules are defined dynamically in Livebook cells.

For working examples of module-based tap, map_left, and filter_or_else, see the test suite at test/monad/dsl_test.exs.

tap with Modules

# Example module pattern (works in compiled code):
#
# defmodule HeroLogger do
#   @behaviour Funx.Monad.Either.Dsl.Behaviour
#   use Funx.Monad.Either
#
#   def run(hero, opts, _env) do
#     prefix = Keyword.get(opts, :prefix, "HERO")
#     IO.puts("#{prefix}: #{inspect(hero)}")
#     hero  # tap returns the original value unchanged
#   end
# end
#
# # Use with bare module:
# either "1" do
#   bind HeroRegistry.parse_hero_id()
#   tap HeroLogger
#   bind HeroRegistry.fetch_hero()
# end
#
# # Use with module and options:
# either "1" do
#   bind HeroRegistry.parse_hero_id()
#   tap {HeroLogger, prefix: "Parsed ID"}
#   bind HeroRegistry.fetch_hero()
#   tap {HeroLogger, prefix: "Fetched Hero"}
#   map fn hero -> hero.name end
# end

map_left with Modules

# Example module pattern (works in compiled code):
#
# defmodule ErrorFormatter do
#   @behaviour Funx.Monad.Either.Dsl.Behaviour
#   use Funx.Monad.Either
#
#   def run(error, opts, _env) do
#     context = Keyword.get(opts, :context, "Error")
#     severity = Keyword.get(opts, :severity, "ERROR")
#     "[#{severity}] #{context}: #{error}"
#   end
# end
#
# # Use with bare module:
# either "invalid" do
#   bind HeroRegistry.parse_hero_id()
#   map_left ErrorFormatter
# end
#
# # Use with module and options:
# either "invalid" do
#   bind HeroRegistry.parse_hero_id()
#   map_left {ErrorFormatter, context: "Hero ID Parsing", severity: "WARN"}
# end

filter_or_else with Modules

# Example module pattern (works in compiled code):
#
# defmodule IsEliteHero do
#   @behaviour Funx.Monad.Either.Dsl.Behaviour
#   use Funx.Monad.Either
#
#   def run(hero, opts, _env) do
#     min_level = Keyword.get(opts, :min_level, 10)
#     hero.level >= min_level
#   end
# end
#
# # Use with bare module:
# either "1" do
#   bind HeroRegistry.parse_hero_id()
#   bind HeroRegistry.fetch_hero()
#   filter_or_else IsEliteHero, fn -> "Only elite heroes allowed" end
# end
#
# # Use with module and options:
# either "1" do
#   bind HeroRegistry.parse_hero_id()
#   bind HeroRegistry.fetch_hero()
#   filter_or_else {IsEliteHero, min_level: 8}, fn -> "Hero level too low" end
# end

Output Formats

Default: Either

By default, the DSL returns an Either value:

either "1" do
  bind HeroRegistry.parse_hero_id()
  bind HeroRegistry.fetch_hero()
  map fn hero -> hero.name end
end
either "invalid" do
  bind HeroRegistry.parse_hero_id()
  bind HeroRegistry.fetch_hero()
  map fn hero -> hero.name end
end

Tuple Format

Use as: :tuple to get {:ok, value} or {:error, reason}:

either "1", as: :tuple do
  bind HeroRegistry.parse_hero_id()
  bind HeroRegistry.fetch_hero()
  map fn hero -> hero.name end
end
either "invalid", as: :tuple do
  bind HeroRegistry.parse_hero_id()
  bind HeroRegistry.fetch_hero()
  map fn hero -> hero.name end
end

Raise Format

Use as: :raise to unwrap the value or raise an error:

either "1", as: :raise do
  bind HeroRegistry.parse_hero_id()
  bind HeroRegistry.fetch_hero()
  map fn hero -> hero.name end
end
# This will raise a RuntimeError with the error message:
try do
  either "invalid", as: :raise do
    bind HeroRegistry.parse_hero_id()
    bind HeroRegistry.fetch_hero()
    map fn hero -> hero.name end
  end
rescue
  e in RuntimeError -> "Caught error: #{e.message}"
end

Either Functions in the DSL

The DSL supports several Either functions that work on the Either value directly.

filter_or_else/3

Filter the Right value based on a predicate. For example, only allow elite heroes:

either "1" do
  bind HeroRegistry.parse_hero_id()
  bind HeroRegistry.fetch_hero()
  filter_or_else fn hero -> hero.level >= 10 end, fn -> "Only elite heroes allowed" end
end
either "2" do
  bind HeroRegistry.parse_hero_id()
  bind HeroRegistry.fetch_hero()
  filter_or_else fn hero -> hero.level >= 10 end, fn -> "Only elite heroes allowed" end
end
# Hero 999 doesn't exist, so this fails at fetch
either "999" do
  bind HeroRegistry.parse_hero_id()
  bind HeroRegistry.fetch_hero()
  filter_or_else fn hero -> hero.level >= 10 end, fn -> "Only elite heroes allowed" end
end

or_else/2

Provide a fallback if the value is Left:

# If hero not found, use a default sidekick
either "999" do
  bind HeroRegistry.parse_hero_id()
  bind HeroRegistry.fetch_hero()
  or_else fn -> right(%{id: 0, name: "Rookie Sidekick", power: "enthusiasm", active: true, level: 1}) end
end

map_left/2

Transform the error value in a Left:

either "invalid" do
  bind HeroRegistry.parse_hero_id()
  map_left fn error -> "Hero registry error: #{error}" end
end

flip/1

Swap Left and Right. This can be useful for inverting logic:

# Flip turns success into failure
either "1" do
  bind HeroRegistry.parse_hero_id()
  flip()
end
# Flip turns failure into success
either "invalid" do
  bind HeroRegistry.parse_hero_id()
  flip()
end

Validation

The DSL supports validation with error accumulation using the validate/2 function. Validators are modules that implement the run/3 callback.

Note: Due to Livebook’s module compilation behavior, the validation examples below are commented out. These patterns work correctly in compiled Elixir code (tests, applications) but may not work reliably when modules are defined dynamically in Livebook cells.

For working examples of validation, see the test suite at test/monad/dsl_test.exs.

# Example validator module pattern (works in compiled code):
#
# defmodule PositiveNumber do
#   @behaviour Funx.Monad.Either.Dsl.Behaviour
#   use Funx.Monad.Either
#
#   def run(value, _opts, _env) when is_number(value) and value > 0 do
#     right(value)
#   end
#
#   def run(value, _opts, _env) when is_number(value) do
#     left("Must be positive: #{value}")
#   end
#
#   def run(value, _opts, _env) do
#     left("Expected number, got: #{inspect(value)}")
#   end
# end
#
# defmodule EvenNumber do
#   @behaviour Funx.Monad.Either.Dsl.Behaviour
#   use Funx.Monad.Either
#
#   def run(value, _opts, _env) when is_number(value) and rem(value, 2) == 0 do
#     right(value)
#   end
#
#   def run(value, _opts, _env) when is_number(value) do
#     left("Must be even: #{value}")
#   end
#
#   def run(value, _opts, _env) do
#     left("Expected number, got: #{inspect(value)}")
#   end
# end
#
# defmodule RangeValidator do
#   @behaviour Funx.Monad.Either.Dsl.Behaviour
#   use Funx.Monad.Either
#
#   def run(value, opts, _env) when is_number(value) do
#     min = Keyword.get(opts, :min, 0)
#     max = Keyword.get(opts, :max, 100)
#
#     if value > min and value < max do
#       right(value)
#     else
#       left("Must be between #{min} and #{max}: #{value}")
#     end
#   end
#
#   def run(value, _opts, _env) do
#     left("Expected number, got: #{inspect(value)}")
#   end
# end
# Usage examples (work in compiled code):
#
# # All validations pass - use bare module names
# either 4 do
#   validate [PositiveNumber, EvenNumber]
# end
#
# # Multiple validations fail - errors are accumulated
# either -3 do
#   validate [PositiveNumber, EvenNumber]
# end
#
# # Chain validations
# either 4 do
#   validate [PositiveNumber, EvenNumber]
#   validate {RangeValidator, min: 0, max: 100}
# end
#
# # Mix bare modules with modules that have options
# either 50 do
#   validate [PositiveNumber, {RangeValidator, min: 0, max: 100}]
# end

Input Lifting

The DSL automatically lifts various input types into the Either context:

Plain Values

# Plain values are wrapped in Right
either 1 do
  bind HeroRegistry.fetch_hero()
  map fn hero -> hero.name end
end

Result Tuples

# {:ok, value} becomes Right - useful when integrating with Elixir code
either {:ok, 1} do
  bind HeroRegistry.fetch_hero()
  map fn hero -> hero.name end
end
# {:error, reason} becomes Left
either {:error, "Database connection failed"} do
  bind HeroRegistry.fetch_hero()
  map fn hero -> hero.name end
end

Either Values

# Either values pass through unchanged
either right(1) do
  bind HeroRegistry.fetch_hero()
  map fn hero -> hero.name end
end
# Left values short-circuit immediately
either left("Session expired") do
  bind HeroRegistry.fetch_hero()
  map fn hero -> hero.name end
end

Advanced: Module-Based Operations

You can create reusable operations as modules that implement the run/3 callback. This is useful for operations you’ll use across many pipelines:

defmodule FormatHeroName do
  @behaviour Funx.Monad.Either.Dsl.Behaviour
  use Funx.Monad.Either

  def run(hero, opts, _env) do
    case Keyword.get(opts, :style, :normal) do
      :uppercase -> {:ok, %{hero | name: String.upcase(hero.name)}}
      :lowercase -> {:ok, %{hero | name: String.downcase(hero.name)}}
      :normal -> {:ok, hero}
    end
  end
end
# Use modules in the DSL with options
either "1" do
  bind HeroRegistry.parse_hero_id()
  bind HeroRegistry.fetch_hero()
  map {FormatHeroName, style: :uppercase}
end
# Without options, uses default behavior
either "1" do
  bind HeroRegistry.parse_hero_id()
  bind HeroRegistry.fetch_hero()
  map FormatHeroName
end

Compile-Time Warnings

The DSL provides helpful compile-time warnings to catch common mistakes:

Using bind with Non-Either Returns

If you use bind with a function that returns a plain value instead of an Either or result tuple, you’ll get a compile-time warning:

# This will warn at compile time:
# either 5 do
#   bind fn x -> x * 2 end  # Should use 'map' instead
# end

Using map with Either Returns

If you use map with a function that returns an Either or result tuple, you’ll get a compile-time warning about double-wrapping:

# This will warn at compile time:
# either 5 do
#   map fn x -> right(x * 2) end  # Should use 'bind' instead
# end

Summary

The Either DSL provides a clean, declarative way to write error-handling pipelines:

  • bind - for operations that return Either or result tuples
  • map - for transformations that return plain values
  • tap - for side effects without changing the value (debugging, logging)
  • Either functions - filter_or_else, or_else, map_left, flip
  • Validation - validate for accumulating multiple errors
  • Module support - use modules with bind, map, tap, map_left, and filter_or_else for reusable operations
  • Options support - pass options to modules using {Module, opts} syntax
  • Output formats - :either, :tuple, or :raise
  • Auto-lifting - automatic conversion of function calls to work in pipelines
  • Compile-time safety - warnings for common mistakes

Module Support Summary

All these operations support modules that implement run/3:

  • bind Module - module returns Either or tuple
  • map Module - module returns plain value
  • tap Module - module performs side effect, returns value unchanged
  • map_left Module - module transforms Left value
  • filter_or_else Module, fallback - module returns boolean predicate
  • validate [Module1, Module2] - modules validate and accumulate errors

Use {Module, key: value} syntax to pass options to any module’s run/3 function.

This makes error handling explicit, composable, and easy to reason about!