Funx.Monad.Maybe.Dsl
Mix.install([
{:funx, github: "JKWA/funx", ref: "89f8e6f"}
])
Overview
The Funx.Monad.Maybe.Dsl module provides a macro-based DSL for writing declarative pipelines that may return nothing. Instead of manually chaining bind, map, and other Maybe operations, you can express your logic in a clean, readable way.
The DSL automatically:
- Lifts input values into the Maybe context
- Handles missing values by short-circuiting on the first Nothing
- Provides compile-time warnings for common mistakes
- Supports multiple output formats (Maybe, nil, or raise)
Setup
use Funx.Monad.Maybe
Basic Usage
Understanding Maybe: Conditional Existence
Maybe is not about “missing data” or “errors.” It’s about conditional existence in domain contexts.
Consider a system that processes geometric shapes. A shape can be a circle or a rectangle. Both are valid shapes, but radius only exists for circles.
This isn’t “a rectangle with a missing radius” - that’s nonsense. A rectangle doesn’t have a radius. The radius exists conditionally - only in the circle context.
Simple Pipeline with bind
The bind operation is used when your function returns a Maybe, Either, result tuple, or nil.
Here are Shapes with different properties:
defmodule Circle do
defstruct [:radius, :color]
end
defmodule Rectangle do
defstruct [:width, :height, :color]
end
defmodule Triangle do
defstruct [:base, :height, :color]
end
defmodule ShapeProcessor do
use Funx.Monad.Maybe
# Does this shape exist as a circle?
def as_circle(%Circle{} = circle), do: just(circle)
def as_circle(_other), do: nothing()
# Does this shape exist as a rectangle?
def as_rectangle(%Rectangle{} = rect), do: just(rect)
def as_rectangle(_other), do: nothing()
# Does this shape exist as a triangle?
def as_triangle(%Triangle{} = tri), do: just(tri)
def as_triangle(_other), do: nothing()
# Extract radius - only exists in circle context
def get_radius(%Circle{radius: r}), do: just(r)
def get_radius(_), do: nothing()
# Extract width - only exists in rectangle context
def get_width(%Rectangle{width: w}), do: just(w)
def get_width(_), do: nothing()
end
circle = %Circle{radius: 6, color: "red"}
rectangle = %Rectangle{width: 10, height: 5, color: "blue"}
triangle = %Triangle{base: 8, height: 6, color: "green"}
Does this shape exist as a circle?
maybe circle do
bind ShapeProcessor.as_circle()
bind ShapeProcessor.get_radius()
end
Yes!
Does this shape exist as a circle?
maybe rectangle do
bind ShapeProcessor.as_circle()
bind ShapeProcessor.get_radius()
end
No, it’s a rectangle.
This isn’t an error - rectangles are valid, they just don’t exist in the circle context
Does this shape have a width?
maybe circle do
bind ShapeProcessor.as_rectangle()
bind ShapeProcessor.get_width()
end
Nope
Does this shape have a width?
maybe rectangle do
bind ShapeProcessor.as_rectangle()
bind ShapeProcessor.get_width()
end
Yep, it’s a rectangle.
Using map for Transformations
The map operation transforms a value without changing the Maybe context. Use it when your function returns a plain value, not a Maybe.
Calculate area if shape is a circle:
maybe circle do
bind ShapeProcessor.as_circle()
bind ShapeProcessor.get_radius()
map fn r -> :math.pi() * r * r end
end
Calculate area if shape is a rectangle:
maybe rectangle do
bind ShapeProcessor.as_rectangle()
map fn rect -> rect.width * rect.height end
end
Using tap for Side Effects
The tap operation executes a side-effect function on a Just value and returns the original Maybe unchanged. This is useful for debugging, logging, or performing side effects without changing the value.
If the Maybe is Nothing, the tap function is not called.
Side effect only runs if shape is a circle:
maybe circle do
bind ShapeProcessor.as_circle()
tap fn c -> IO.inspect(c.radius, label: "circle radius") end
bind ShapeProcessor.get_radius()
end
No side effect - rectangle doesn’t exist as a circle:
maybe rectangle do
bind ShapeProcessor.as_circle()
tap fn c -> IO.inspect(c.radius, label: "should not see this") end
bind ShapeProcessor.get_radius()
end
Protocol Functions
Maybe DSL integrates with Funx protocols for filtering and guarding based on predicates.
Conditional Filtering
Filter based on a predicate. If the predicate returns false, the result becomes Nothing.
Get only large circles (radius > 5):
maybe circle do
bind ShapeProcessor.as_circle()
filter fn c -> c.radius > 5 end
map fn c -> c.radius end
end
Small circle fails the filter:
small_circle = %Circle{radius: 3, color: "blue"}
maybe small_circle do
bind ShapeProcessor.as_circle()
filter fn c -> c.radius > 5 end
map fn c -> c.radius end
end
Predicate-Based Gates
Guard is similar to filter but uses guard syntax for clarity.
Only process red shapes:
maybe circle do
guard fn shape -> shape.color == "red" end
bind ShapeProcessor.as_circle()
map fn c -> c.radius end
end
Blue circle fails the guard:
blue_circle = %Circle{radius: 5, color: "blue"}
maybe blue_circle do
guard fn shape -> shape.color == "red" end
bind ShapeProcessor.as_circle()
map fn c -> c.radius end
end
Output Formats
Default: Maybe
By default, the DSL returns a Maybe value.
Returns Just(radius):
maybe circle do
bind ShapeProcessor.as_circle()
bind ShapeProcessor.get_radius()
end
Returns Nothing - rectangle doesn’t exist as a circle:
maybe rectangle do
bind ShapeProcessor.as_circle()
bind ShapeProcessor.get_radius()
end
Nil Format
Use as: :nil to unwrap the value or get nil:
maybe circle, as: :nil do
bind ShapeProcessor.as_circle()
bind ShapeProcessor.get_radius()
end
maybe rectangle, as: :nil do
bind ShapeProcessor.as_circle()
bind ShapeProcessor.get_radius()
end
Raise Format
Use as: :raise to unwrap the value or raise when value doesn’t exist in the context:
maybe circle, as: :raise do
bind ShapeProcessor.as_circle()
bind ShapeProcessor.get_radius()
end
maybe rectangle, as: :raise do
bind ShapeProcessor.as_circle()
bind ShapeProcessor.get_radius()
end
Filter and Transform
Filter and transform in one step - returns Nothing if the transformation returns :error.
Get diameter if circle and radius > 3:
maybe circle do
bind ShapeProcessor.as_circle()
filter_map fn c ->
if c.radius > 3 do
{:ok, c.radius * 2}
else
:error
end
end
end
Small circle fails filter_map:
small_circle = %Circle{radius: 2, color: "blue"}
maybe small_circle do
bind ShapeProcessor.as_circle()
filter_map fn c ->
if c.radius > 3 do
{:ok, c.radius * 2}
else
:error
end
end
end
Providing Fallbacks
Provide a fallback when value doesn’t exist in the expected context.
If not a circle, use a default radius:
maybe rectangle do
bind ShapeProcessor.as_circle()
bind ShapeProcessor.get_radius()
or_else fn -> just(1) end
end
Input Lifting
The DSL automatically lifts various input types into the Maybe context.
Plain values → Just
maybe circle do
map fn c -> c.color end
end
{:ok, value} → Just (integrating with Elixir conventions):
maybe {:ok, circle} do
bind ShapeProcessor.as_circle()
map fn c -> c.radius end
end
{:error, _} → Nothing:
maybe {:error, "not found"} do
bind ShapeProcessor.as_circle()
map fn c -> c.radius end
end
Maybe values pass through unchanged:
maybe just(circle) do
bind ShapeProcessor.as_circle()
map fn c -> c.radius end
end
Nothing short-circuits immediately:
maybe nothing() do
bind ShapeProcessor.as_circle()
map fn c -> c.radius end
end
Either values are converted (Right → Just, Left → Nothing):
use Funx.Monad.Either
maybe right(circle) do
bind ShapeProcessor.as_circle()
map fn c -> c.radius end
end
nil → Nothing:
maybe nil do
bind ShapeProcessor.as_circle()
map fn c -> c.radius end
end
Advanced: Module-Based Operations
You can create reusable operations as modules that implement the run_maybe/3 callback. This is useful for domain boundaries you’ll check across many pipelines:
defmodule ScaleShape do
@behaviour Funx.Monad.Maybe.Dsl.Behaviour
use Funx.Monad.Maybe
def run_maybe(shape, opts, _env) do
factor = Keyword.get(opts, :factor, 2)
case shape do
%Circle{radius: r} = c -> %{c | radius: r * factor}
%Rectangle{width: w, height: h} = r -> %{r | width: w * factor, height: h * factor}
%Triangle{base: b, height: h} = t -> %{t | base: b * factor, height: h * factor}
end
end
end
Use modules in the DSL with options:
maybe circle do
bind ShapeProcessor.as_circle()
map {ScaleShape, factor: 3}
end
Without options, uses default behavior (2x):
maybe circle do
bind ShapeProcessor.as_circle()
map ScaleShape
end
Applicative Functor Support
The Maybe DSL supports applicative functors with the ap operation, allowing you to apply a function wrapped in a Maybe to a value wrapped in a Maybe.
Note: In the DSL, the pipeline value should be the function, and you pass the value to ap:
Example of ap with a simple function:
Pipeline contains the function, ap receives the value
add_five = just(fn x -> x + 5 end)
maybe add_five do
ap just(10)
end
ap with Nothing function returns Nothing:
nothing_fn = nothing()
maybe nothing_fn do
ap just(10)
end
ap on Nothing value returns Nothing:
add_five = just(fn x -> x + 5 end)
maybe add_five do
ap nothing()
end
Summary
Maybe is not just about missing data or errors. It’s about conditional existence in domain contexts.
When you ask “does this value exist as X?”, Maybe gives you:
-
Just(value)- yes, it exists in this context -
Nothing- no, it doesn’t exist in this context (which is not an error!)
Core Operations
-
bind- for operations that check domain contexts (returns Maybe, Either, result tuples, or nil) -
map- for transformations on values that exist in the current context -
tap- for side effects on values in the current context (debugging, logging) -
ap- for applicative functor application
Protocol Functions
-
filter- keep only values that match a predicate -
filter_map- filter and transform in one step -
guard- predicate-based gates
Maybe Functions
-
or_else- provide fallbacks when values don’t exist in expected contexts
Output Formats
-
:maybe(default) - returnsJust(value)orNothing -
:nil- unwraps to value ornil -
:raise- unwraps or raises when value doesn’t exist in context
Integration
- Auto-lifting - automatic conversion of Maybe, Either, tuples, nil, and plain values
-
Module support - reusable domain boundary checks via
run_maybe/3callback -
Options support - pass options to modules using
{Module, opts}syntax