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 -
validatefor accumulating multiple errors -
Module support - use modules with
bind,map,tap,map_left, andfilter_or_elsefor 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!