Current section
Files
Jump to
Current section
Files
lib/monad/effect.ex
defmodule Funx.Monad.Effect do
@moduledoc """
The `Funx.Monad.Effect` module defines the `Effect` monad, which represents asynchronous computations
that may succeed (`Right`) or fail (`Left`). Execution is deferred until explicitly run, making
`Effect` useful for structuring lazy, asynchronous workflows.
This module integrates tracing and telemetry, making it suitable for observability in concurrent
Elixir systems. All effects carry a `Effect.Context`, which links operations and records spans
when `run/2` is called.
## Constructors
* `right/1` – Wraps a value in a successful `Right` effect.
* `left/1` – Wraps a value in a failing `Left` effect.
* `pure/1` – Alias for `right/1`.
## Execution
* `run/2` – Executes the deferred effect and returns an `Either` result (`Right` or `Left`).
You may pass `:task_supervisor` in the `opts` to run the effect under a specific `Task.Supervisor`. This supervises the top-level task, any internal tasks spawned within the effect function are not supervised.
## Sequencing
* `sequence/1` – Runs a list of effects, stopping at the first `Left`.
* `traverse/2` – Applies a function returning an `Effect` to each element of a list, sequencing results.
* `sequence_a/2` – Runs a list of effects, collecting all `Left` errors instead of short-circuiting.
* `traverse_a/3` – Like `traverse/2`, but accumulates errors across the list.
## Validation
* `validate/2` – Validates a value using one or more effectful validators.
## Error Handling
* `map_left/2` – Transforms a `Left` using a function, leaving `Right` values unchanged.
* `flip_either/1` – Inverts the success and failure branches of an `Effect`.
* `tap/2` – Executes a side-effect function on a `Right` value, returning the original `Effect` unchanged.
## Lifting
* `lift_func/2` – Lifts a thunk that returns any value into an `Effect`, wrapping it in `Right`. If the thunk raises, the error is captured as a `Left(EffectError)`.
* `lift_either/2` – Lifts a thunk that returns an `Either` into an `Effect`. Evaluation is deferred until the effect is run. Errors are also captured and wrapped in `Left(EffectError)`.
* `lift_maybe/3` – Lifts a `Maybe` into an `Effect`, using a fallback error if the value is `Nothing`.
* `lift_predicate/3` – Lifts a predicate check into an `Effect`. Returns `Right(value)` if the predicate passes; otherwise returns `Left(fallback)`.
## Reader Operations
* `ask/0` – Returns the environment passed to `run/2` as a `Right`.
* `asks/1` – Applies a function to the environment passed to `run/2`, wrapping the result in a `Right`.
* `fail/0` – Returns the environment passed to `run/2` as a `Left`.
* `fails/1` – Applies a function to the environment passed to `run/2`, wrapping the result in a `Left`.
## Elixir Interop
* `from_result/2` – Converts a `{:ok, _}` or `{:error, _}` tuple into an `Effect`.
* `to_result/1` – Converts an `Effect` to `{:ok, _}` or `{:error, _}`.
* `from_try/2` – Wraps a function that may raise, returning Right on success, or Left if an exception is raised.
* `to_try!/1` – Extracts the value from a `Right`, or raises an exception if `Left`.
## Protocols
The Left and Right structs implement the following protocols:
* Funx.Monad – Provides map/2, ap/2, and bind/2 for compositional workflows.
Although protocol implementations are defined on Left and Right individually, the behavior
is unified under the Effect abstraction.
This module enables structured concurrency, error handling, and observability in
asynchronous workflows.
## Telemetry
The `run/2` function emits telemetry using `:telemetry.span/3`.
### Events
* `[:funx, :effect, :run, :start]`
* `[:funx, :effect, :run, :stop]`
### Measurements
* `:monotonic_time` – included in both `:start` and `:stop` events.
* `:system_time` – included only in the `:start` event.
* `:duration` – included only in the `:stop` event.
### Metadata
* `:timeout` – the timeout in milliseconds passed to `run/2`.
* `:result` – a summarized version of the result using `Funx.Summarizable`.
* `:effect_type` – `:right` or `:left`, depending on the effect being run.
* `:status` – `:ok` if the result is a `Right`, or `:error` if it's a `Left`.
* `:trace_id` – optional value used to correlate traces across boundaries.
* `:span_name` – optional name for the span (defaults to `"funx.effect.run"`).
* `:telemetry_span_context` – reference to correlate `:start` and `:stop` events.
### Example
:telemetry.attach(
"effect-run-handler",
[:funx, :effect, :run, :stop],
fn event, measurements, metadata, _config ->
IO.inspect({event, measurements, metadata}, label: "Effect telemetry")
end,
nil
)
"""
import Funx.Appendable, only: [append: 2, coerce: 1]
import Funx.Monad, only: [map: 2]
alias Funx.Errors.EffectError
alias Funx.Monad.{Effect, Either, Maybe}
alias Effect.{Left, Right}
alias Maybe.{Just, Nothing}
@typedoc """
Represents a deferred computation in the `Effect` monad that may either succeed (`Right`) or fail (`Left`).
This type unifies `Effect.Right.t/1` and `Effect.Left.t/1` under a common interface, allowing code to
operate over asynchronous effects regardless of success or failure outcome.
Each variant carries a `context` for telemetry and a deferred `effect` function that takes an environment.
"""
@type t(left, right) :: Effect.Left.t(left) | Effect.Right.t(right)
@doc """
Wraps a value in the `Right` variant of the `Effect` monad, representing a successful asynchronous computation.
This is an alias for `pure/2`. You may optionally provide execution context, either as a keyword list or
a `%Funx.Monad.Effect.Context{}` struct. The context is attached to the effect and propagated during execution.
## Examples
iex> result = Funx.Monad.Effect.right(42)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Right{right: 42}
iex> context = Funx.Monad.Effect.Context.new(trace_id: "custom-id", span_name: "from right")
iex> result = Funx.Monad.Effect.right(42, context)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Right{right: 42}
"""
@spec right(right, Effect.Context.opts_or_context()) :: t(term(), right) when right: term()
def right(value, opts_or_context \\ []), do: Right.pure(value, opts_or_context)
@doc """
Alias for `right/2`.
Wraps a value in the `Right` variant of the `Effect` monad, representing a successful asynchronous computation.
Accepts either a keyword list of context options or a `Effect.Context` struct.
## Examples
iex> result = Funx.Monad.Effect.pure(42)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Right{right: 42}
iex> context = Funx.Monad.Effect.Context.new(trace_id: "custom-id", span_name: "pure example")
iex> result = Funx.Monad.Effect.pure(42, context)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Right{right: 42}
"""
@spec pure(right, Effect.Context.opts_or_context()) :: t(term(), right) when right: term()
def pure(value, opts_or_context \\ []), do: right(value, opts_or_context)
@doc """
Wraps a value in the `Left` variant of the `Effect` monad, representing a failed asynchronous computation.
Accepts either a keyword list of context options or a `Effect.Context` struct.
## Examples
iex> result = Funx.Monad.Effect.left("error")
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Left{left: "error"}
iex> context = Funx.Monad.Effect.Context.new(trace_id: "err-id", span_name: "failure")
iex> result = Funx.Monad.Effect.left("error", context)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Left{left: "error"}
"""
@spec left(left, Effect.Context.opts_or_context()) :: t(left, term()) when left: term()
def left(value, opts_or_context \\ []), do: Left.pure(value, opts_or_context)
@doc """
Returns a `Funx.Monad.Effect.Right` that yields the environment passed to `Funx.Monad.Effect.run/2`.
This is the Reader-style `ask`, used to access the full environment inside an effectful computation.
## Example
iex> Funx.Monad.Effect.ask()
...> |> Funx.Monad.map(& &1[:region])
...> |> Funx.Monad.Effect.run(%{region: "us-west"})
%Funx.Monad.Either.Right{right: "us-west"}
"""
@spec ask :: Funx.Monad.Effect.Right.t()
def ask, do: Right.ask()
@doc """
Returns a `Funx.Monad.Effect.Left` that fails with the entire environment passed to `Funx.Monad.Effect.run/2`.
This is the Reader-style equivalent of `ask/0`, but marks the environment as a failure.
Useful when the presence of certain runtime data should short-circuit execution.
## Example
iex> Funx.Monad.Effect.fail()
...> |> Funx.Monad.Effect.run(%{error: :invalid_token})
%Funx.Monad.Either.Left{left: %{error: :invalid_token}}
"""
@spec fail :: Left.t()
def fail, do: Left.ask()
@doc """
Returns a `Funx.Monad.Effect.Right` that applies the given function to the environment passed to `Funx.Monad.Effect.run/2`.
This allows extracting a value from the environment and using it in an effectful computation,
following the Reader pattern.
## Example
iex> Funx.Monad.Effect.asks(fn env -> env[:user] end)
...> |> Funx.Monad.bind(fn user -> Funx.Monad.Effect.right(user) end)
...> |> Funx.Monad.Effect.run(%{user: "alice"})
%Funx.Monad.Either.Right{right: "alice"}
"""
@spec asks((term() -> term())) :: Right.t()
def asks(f), do: Right.asks(f)
@doc """
Returns a `Funx.Monad.Effect.Left` that applies the given function to the environment passed to `Funx.Monad.Effect.run/2`.
This is the failure-side equivalent of `asks/1`, used to produce an error effect based on runtime context.
## Example
iex> Funx.Monad.Effect.fails(fn env -> {:missing_key, env} end)
...> |> Funx.Monad.Effect.run(%{input: nil})
%Funx.Monad.Either.Left{left: {:missing_key, %{input: nil}}}
"""
@spec fails((term() -> term())) :: Left.t()
def fails(f), do: Left.asks(f)
@doc """
Runs the `Effect` and returns the result, awaiting the task if necessary.
You may provide optional telemetry metadata using `opts`, such as `:span_name`
to promote the current context with a new label.
## Options
* `:span_name` – (optional) promotes the trace to a new span with the given name.
## Examples
iex> result = Funx.Monad.Effect.right(42)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Right{right: 42}
iex> result = Funx.Monad.Effect.right(42, span_name: "initial")
iex> Funx.Monad.Effect.run(result, span_name: "promoted")
%Funx.Monad.Either.Right{right: 42}
"""
@spec run(t(left, right)) :: Either.t(left, right)
when left: term(), right: term()
@spec run(t(left, right), map()) :: Either.t(left, right)
when left: term(), right: term()
@spec run(t(left, right), keyword()) :: Either.t(left, right)
when left: term(), right: term()
@spec run(t(left, right), map(), keyword()) :: Either.t(left, right)
when left: term(), right: term()
def run(effect) when is_struct(effect, Effect.Right) or is_struct(effect, Effect.Left),
do: run(effect, %{}, [])
def run(effect, env)
when (is_struct(effect, Effect.Right) or is_struct(effect, Effect.Left)) and is_map(env),
do: run(effect, env, [])
def run(effect, opts)
when (is_struct(effect, Funx.Monad.Effect.Right) or
is_struct(effect, Funx.Monad.Effect.Left)) and
is_list(opts) do
env = Keyword.get(opts, :env, %{})
run(effect, env, opts)
end
# NOTE: Coveralls is confused by the guard clause in the function head
# coveralls-ignore-next-line
def run(%{context: %Effect.Context{} = context} = effect, env, opts \\ [])
when (is_struct(effect, Funx.Monad.Effect.Right) or
is_struct(effect, Funx.Monad.Effect.Left)) and
is_map(env) and
is_list(opts) do
context =
opts
|> maybe_promote_trace(context)
|> Effect.Context.override(Keyword.delete(opts, :span_name))
timeout = context.timeout || Funx.Config.timeout()
span_name = context.span_name || Funx.Config.default_span_name()
prefix = Funx.Config.telemetry_prefix() ++ [:effect, :run]
if Funx.Config.telemetry_enabled?() do
:telemetry.span(prefix, %{timeout: timeout, span_name: span_name}, fn ->
result = execute_effect(effect, timeout, env, opts)
{result, build_metadata(%{effect | context: context}, result, context)}
end)
else
execute_effect(effect, timeout, env, opts)
end
end
defp maybe_promote_trace(opts, context) do
case Keyword.get(opts, :span_name) do
nil -> context
span_name -> Effect.Context.promote_trace(context, span_name)
end
end
defp execute_effect(%Right{effect: eff}, timeout, env, opts) do
case Keyword.fetch(opts, :task_supervisor) do
{:ok, sup} ->
Task.Supervisor.async_nolink(sup, fn ->
task = eff.(env)
Task.await(task, timeout)
end)
|> await(timeout)
:error ->
await(eff.(env), timeout)
end
end
defp execute_effect(%Left{effect: eff}, timeout, env, opts) do
case Keyword.fetch(opts, :task_supervisor) do
{:ok, sup} ->
Task.Supervisor.async_nolink(sup, fn ->
task = eff.(env)
Task.await(task, timeout)
end)
|> await(timeout)
:error ->
await(eff.(env), timeout)
end
end
defp build_metadata(effect, result, %Effect.Context{} = context) do
%{
result: Funx.Config.summarizer().(result),
effect_type: if(match?(%Either.Right{}, result), do: :right, else: :left),
status: if(match?(%Either.Right{}, result), do: :ok, else: :error),
span_name: context.span_name,
trace_id: context.trace_id
}
|> maybe_put_parent_trace_id(effect)
end
defp maybe_put_parent_trace_id(meta, %{context: %Effect.Context{parent_trace_id: nil}}),
do: meta
defp maybe_put_parent_trace_id(meta, %{context: %Effect.Context{parent_trace_id: pid}}),
do: Map.put(meta, :parent_trace_id, pid)
@spec await(Task.t(), timeout()) :: Either.t(any(), any())
def await(task, timeout \\ 5000) do
try do
case Task.yield(task, timeout) || Task.shutdown(task) do
{:ok, %Either.Right{} = right} -> right
{:ok, %Either.Left{} = left} -> left
{:ok, other} -> Either.left(EffectError.new(:run, {:invalid_result, other}))
nil -> Either.left(EffectError.new(:run, :timeout))
end
rescue
error -> Either.left(EffectError.new(:run, error))
end
end
@doc """
Lifts a thunk into the `Effect` monad, wrapping its result in a `Right`.
This function defers execution of the given zero-arity function (`thunk`) until the effect is run.
The result is automatically wrapped as `Either.Right`.
You may also pass a context or options (`opts`) to configure telemetry or span metadata.
If the thunk raises an exception, it is caught and returned as a `Left` containing an `EffectError` tagged with `:lift`.
## Examples
iex> result = Funx.Monad.Effect.lift_func(fn -> 42 end)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Right{right: 42}
iex> result = Funx.Monad.Effect.lift_func(fn -> raise "boom" end)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Left{
left: %Funx.Errors.EffectError{stage: :lift_func, reason: %RuntimeError{message: "boom"}}
}
"""
@spec lift_func((-> right), Effect.Context.opts_or_context()) :: t(left, right)
when left: term(), right: term()
def lift_func(thunk, opts \\ []) when is_function(thunk, 0) do
%Right{
effect: fn _env ->
Task.async(fn ->
try do
Either.pure(thunk.())
rescue
error -> Either.left(EffectError.new(:lift_func, error))
end
end)
end,
context: Effect.Context.new(opts)
}
end
@doc """
Lifts a value into the `Effect` monad based on a predicate.
If the predicate returns true, the value is wrapped in `Right`.
Otherwise, the result of calling `on_false` with the value is wrapped in `Left`.
Optional context metadata (e.g. `:span_name`, `:trace_id`) can be passed via `opts`.
## Examples
iex> result = Funx.Monad.Effect.lift_predicate(10, &(&1 > 5), fn x -> "\#{x} is too small" end)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Right{right: 10}
iex> result = Funx.Monad.Effect.lift_predicate(3, &(&1 > 5), fn x -> "\#{x} is too small" end)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Left{left: "3 is too small"}
"""
@spec lift_predicate(
term(),
(term() -> boolean()),
(term() -> left),
Effect.Context.opts_or_context()
) ::
t(left, term())
when left: term()
def lift_predicate(value, predicate, on_false, opts \\ []) do
if predicate.(value) do
right(value, opts)
else
left(on_false.(value), opts)
end
end
@doc """
Lifts a thunk that returns an `Either` into the `Effect` monad.
Instead of passing an `Either` value directly, you provide a zero-arity function (`thunk`) that returns one.
This defers execution until the effect is run, allowing integration with tracing and composable pipelines.
You may also pass a context or options (`opts`) to configure telemetry or span metadata.
If the thunk raises an exception, it is caught and returned as a `Left` containing an `EffectError` tagged with `:lift`.
## Examples
iex> result = Funx.Monad.Effect.lift_either(fn -> %Funx.Monad.Either.Right{right: 42} end)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Right{right: 42}
iex> result = Funx.Monad.Effect.lift_either(fn -> %Funx.Monad.Either.Left{left: "error"} end)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Left{left: "error"}
"""
@spec lift_either((-> Either.t(left, right)), Effect.Context.opts_or_context()) ::
t(left, right)
when left: term(), right: term()
def lift_either(thunk, opts \\ []) when is_function(thunk, 0) do
%Right{
effect: fn _env ->
Task.async(fn ->
try do
case thunk.() do
%Either.Right{} = right -> right
%Either.Left{} = left -> left
end
rescue
error -> Either.left(EffectError.new(:lift_either, error))
end
end)
end,
context: Effect.Context.new(opts)
}
end
@doc """
Converts a `Maybe` value into the `Effect` monad.
If the `Maybe` is `Just`, the value is wrapped in `Right`.
If it is `Nothing`, the result of `on_none` is wrapped in `Left`.
You can optionally provide context metadata via `opts`.
## Examples
iex> maybe = Funx.Monad.Maybe.just(42)
iex> result = Funx.Monad.Effect.lift_maybe(maybe, fn -> "No value" end)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Right{right: 42}
iex> maybe = Funx.Monad.Maybe.nothing()
iex> result = Funx.Monad.Effect.lift_maybe(maybe, fn -> "No value" end)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Left{left: "No value"}
"""
@spec lift_maybe(Maybe.t(right), (-> left), Effect.Context.opts_or_context()) :: t(left, right)
when left: term(), right: term()
def lift_maybe(maybe, on_none, opts \\ [])
def lift_maybe(%Just{value: value}, _on_none, opts), do: right(value, opts)
def lift_maybe(%Nothing{}, on_none, opts), do: left(on_none.(), opts)
@doc """
Transforms the `Left` branch of an `Effect`.
If the `Effect` resolves to a `Left`, the provided function is applied to the error.
If the `Effect` resolves to a `Right`, the value is returned unchanged.
This function is useful when you want to rewrite or wrap errors without affecting successful computations.
## Examples
iex> effect = Funx.Monad.Effect.left("error")
iex> transformed = Funx.Monad.Effect.map_left(effect, fn e -> "wrapped: " <> e end)
iex> Funx.Monad.Effect.run(transformed)
%Funx.Monad.Either.Left{left: "wrapped: error"}
iex> effect = Funx.Monad.Effect.pure(42)
iex> transformed = Funx.Monad.Effect.map_left(effect, fn _ -> "should not be called" end)
iex> Funx.Monad.Effect.run(transformed)
%Funx.Monad.Either.Right{right: 42}
"""
@spec map_left(t(error, value), (error -> new_error)) :: t(new_error, value)
when error: term(), new_error: term(), value: term()
def map_left(%Right{} = right, _func), do: right
def map_left(%Left{effect: eff, context: context}, func) when is_function(func, 1) do
promoted_context = Effect.Context.promote_trace(context, "map_left")
%Left{
context: promoted_context,
effect: fn env ->
Task.async(fn ->
case Effect.run(%Left{effect: eff, context: context}, env) do
%Either.Left{left: error} ->
%Either.Left{left: func.(error)}
%Either.Right{} = right ->
right
end
end)
end
}
end
@doc """
Executes a side-effect function on a `Right` value and returns the original `Effect` unchanged.
If the `Effect` is `Left`, the function is not called and the `Left` is returned as-is.
Useful for debugging, logging, telemetry, or performing side effects in the middle of an effectful
pipeline without changing the value.
The side effect function is executed when the effect is run, not when `tap` is called (deferred execution).
## Examples
iex> effect = Funx.Monad.Effect.right(5)
iex> tapped = Funx.Monad.Effect.tap(effect, fn _x -> :ok end)
iex> Funx.Monad.Effect.run(tapped)
%Funx.Monad.Either.Right{right: 5}
iex> effect = Funx.Monad.Effect.left("error")
iex> tapped = Funx.Monad.Effect.tap(effect, fn _x -> :ok end)
iex> Funx.Monad.Effect.run(tapped)
%Funx.Monad.Either.Left{left: "error"}
"""
@spec tap(t(error, value), (value -> any())) :: t(error, value)
when error: term(), value: term()
def tap(%Right{effect: eff, context: context}, func) when is_function(func, 1) do
promoted_context = Effect.Context.promote_trace(context, "tap")
%Right{
context: promoted_context,
effect: fn env ->
Task.async(fn ->
case Effect.run(%Right{effect: eff, context: context}, env) do
%Either.Right{right: value} = result ->
func.(value)
result
%Either.Left{} = left ->
left
end
end)
end
}
end
def tap(%Left{} = left, _func), do: left
@doc """
Inverts the success and failure branches of an `Effect`.
For a `Right`, this reverses the result: a successful value becomes a failure, and
a failure becomes a success. For a `Left`, only failure is expected; if the `Left`
produces a success, it is ignored.
This is useful when you want to reverse the semantics of a computation—treating
an expected error as success, or vice versa.
## Examples
iex> effect = Funx.Monad.Effect.pure(42)
iex> flipped = Funx.Monad.Effect.flip_either(effect)
iex> Funx.Monad.Effect.run(flipped)
%Funx.Monad.Either.Left{left: 42}
iex> effect = Funx.Monad.Effect.left("fail")
iex> flipped = Funx.Monad.Effect.flip_either(effect)
iex> Funx.Monad.Effect.run(flipped)
%Funx.Monad.Either.Right{right: "fail"}
"""
@spec flip_either(t(error, value)) :: t(value, error)
when error: term(), value: term()
def flip_either(%Right{context: context} = right) do
promoted_trace = Effect.Context.promote_trace(context, "flip_either")
%Right{
context: promoted_trace,
effect: fn env ->
Task.async(fn ->
run(right, env)
|> Either.flip()
end)
end
}
end
def flip_either(%Left{context: context} = left) do
promoted_trace = Effect.Context.promote_trace(context, "flip_either")
%Right{
context: promoted_trace,
effect: fn env ->
Task.async(fn ->
run(left, env)
|> Either.flip()
end)
end
}
end
@doc """
Sequences a list of `Effect` computations, running each in order.
If all effects resolve to `Right`, the result is a `Right` containing a list of values.
If any effect resolves to `Left`, the sequencing stops early and that `Left` is returned.
Each effect is executed with its own context context, and telemetry spans are emitted for observability.
## Examples
iex> effects = [Funx.Monad.Effect.right(1), Funx.Monad.Effect.right(2)]
iex> result = Funx.Monad.Effect.sequence(effects)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Right{right: [1, 2]}
iex> effects = [Funx.Monad.Effect.right(1), Funx.Monad.Effect.left("error")]
iex> result = Funx.Monad.Effect.sequence(effects)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Left{left: "error"}
"""
@spec sequence([t(left, right)], Effect.Context.opts_or_context()) :: t(left, [right])
when left: term(), right: term()
def sequence(list, opts \\ []), do: traverse(list, fn x -> x end, opts)
@doc """
Traverses a list with a function that returns `Effect` computations,
running each in sequence and collecting the `Right` results.
If all effects resolve to `Right`, returns a single `Effect` with a list of results.
If any effect resolves to `Left`, the traversal stops early and returns that `Left`.
Each step preserves context context and emits telemetry spans, including nested spans when bound.
## Examples
iex> is_positive = fn num ->
...> Funx.Monad.Effect.lift_predicate(num, fn x -> x > 0 end, fn x -> Integer.to_string(x) <> " is not positive" end)
...> end
iex> result = Funx.Monad.Effect.traverse([1, 2, 3], fn num -> is_positive.(num) end)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Right{right: [1, 2, 3]}
iex> result = Funx.Monad.Effect.traverse([1, -2, 3], fn num -> is_positive.(num) end)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Left{left: "-2 is not positive"}
"""
@spec traverse([input], (input -> t(left, right)), Effect.Context.opts_or_context()) ::
t(left, [right])
when input: term(), left: term(), right: term()
def traverse(list, func), do: traverse(list, func, [])
def traverse([], _func, opts), do: pure([], opts)
def traverse(list, func, opts) when is_list(list) and is_function(func, 1) do
root_context = Effect.Context.new(opts)
list
|> Enum.with_index()
|> Enum.reduce_while(pure([], root_context), fn {item, idx},
%Right{context: acc_ctx, effect: acc_eff} ->
case func.(item) do
%Right{context: item_ctx, effect: item_eff} ->
span_name = "#{root_context.span_name}[#{idx}]"
named_ctx = Effect.Context.default_span_name_if_empty(item_ctx, span_name)
updated_ctx = Effect.Context.promote_trace(named_ctx, "traverse")
{:cont,
%Right{
context: updated_ctx,
effect: fn env ->
Task.async(fn ->
with %Either.Right{right: val} <-
Effect.run(%Right{context: named_ctx, effect: item_eff}, env),
%Either.Right{right: acc_vals} <-
Effect.run(%Right{context: acc_ctx, effect: acc_eff}, env) do
Either.pure([val | acc_vals])
end
end)
end
}}
%Left{context: fail_ctx} = left ->
span_name = "#{root_context.span_name}[#{idx}]"
named_ctx = Effect.Context.default_span_name_if_empty(fail_ctx, span_name)
{:halt, %Left{left | context: named_ctx}}
end
end)
|> map(&:lists.reverse/1)
end
@doc """
Sequences a list of `Effect` computations, collecting all `Right` results
or accumulating all `Left` errors if present.
Unlike `sequence/1`, which stops at the first `Left`, this version continues processing
all effects, returning a list of errors if any failures occur.
Each effect emits its own telemetry span, and error contexts are preserved through tracing.
## Examples
iex> effects = [
...> Funx.Monad.Effect.right(1),
...> Funx.Monad.Effect.left("Error 1"),
...> Funx.Monad.Effect.left("Error 2")
...> ]
iex> result = Funx.Monad.Effect.sequence_a(effects)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Left{left: ["Error 1", "Error 2"]}
"""
@spec sequence_a([t(error, value)], Effect.Context.opts_or_context()) :: t([error], [value])
when error: term(), value: term()
def sequence_a(list, opts \\ []), do: traverse_a(list, fn x -> x end, opts)
@doc """
Traverses a list with a function that returns `Effect` values, combining results
into a single `Effect`. Unlike `traverse/2`, this version accumulates all errors
rather than stopping at the first `Left`.
Each successful computation contributes to the final list of results.
If any computations fail, all errors are collected and returned as a single `Left`.
This function also manages telemetry trace context across all nested effects,
ensuring that span relationships and trace IDs are preserved through the traversal.
## Examples
iex> validate = fn n ->
...> Funx.Monad.Effect.lift_predicate(n, fn x -> x > 0 end, fn x -> Integer.to_string(x) <> " is not positive" end)
...> end
iex> result = Funx.Monad.Effect.traverse_a([1, -2, 3], validate)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Left{left: ["-2 is not positive"]}
iex> result = Funx.Monad.Effect.traverse_a([1, 2, 3], validate)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Right{right: [1, 2, 3]}
"""
@spec traverse_a([input], (input -> t(error, value)), Effect.Context.opts_or_context()) ::
t([error], [value])
when input: term(), error: term(), value: term()
def traverse_a(list, func), do: traverse_a(list, func, [])
def traverse_a([], _func, opts), do: right([], opts)
def traverse_a(list, func, opts) when is_list(list) and is_function(func, 1) do
root_context = Effect.Context.new(opts)
effects =
list
|> Enum.with_index()
|> Enum.map(fn {item, idx} ->
case func.(item) do
%Right{effect: eff, context: ctx} ->
span_ctx =
Effect.Context.default_span_name_if_empty(ctx, "#{root_context.span_name}[#{idx}]")
%Right{context: span_ctx, effect: eff}
%Left{effect: eff, context: ctx} ->
span_ctx =
Effect.Context.default_span_name_if_empty(ctx, "#{root_context.span_name}[#{idx}]")
%Left{context: span_ctx, effect: eff}
end
end)
%Right{
context: root_context,
effect: fn env ->
Task.async(fn ->
tasks = Enum.map(effects, &spawn_effect/1)
results = Enum.map(tasks, &collect_result/1)
{oks, errs} =
Enum.split_with(results, fn
{:ok, _, _} -> true
{:error, _, _} -> false
end)
if errs == [] do
merged_ctx = merge_trace(root_context, Enum.map(oks, &elem(&1, 1)), "traverse_a")
values =
oks
|> Enum.map(fn {:ok, _, val} -> val end)
|> Enum.filter(& &1)
wrap_right(values, merged_ctx)
|> run(env)
else
merged_ctx = merge_trace(root_context, Enum.map(errs, &elem(&1, 1)), "traverse_a")
errors =
errs
|> Enum.map(fn {:error, _, val} -> coerce(val) end)
|> Enum.reduce(&append(&2, &1))
wrap_left(errors, merged_ctx)
|> run(env)
end
end)
end
}
end
defp spawn_effect(%Right{context: ctx, effect: eff}),
do: {:right, ctx, Task.async(fn -> run(%Right{context: ctx, effect: eff}, %{}) end)}
defp spawn_effect(%Left{context: ctx, effect: eff}),
do: {:left, ctx, Task.async(fn -> run(%Left{context: ctx, effect: eff}, %{}) end)}
defp collect_result({:right, ctx, task}) do
case await(task) do
%Either.Right{right: val} -> {:ok, ctx, val}
%Either.Left{left: err} -> {:error, ctx, err}
end
end
defp collect_result({:left, ctx, task}) do
case await(task) do
%Either.Left{left: err} -> {:error, ctx, err}
end
end
defp wrap_right(values, ctx) do
%Right{
context: ctx,
effect: fn _ -> Task.async(fn -> Either.pure(values) end) end
}
end
defp wrap_left(errors, ctx) do
%Left{
context: ctx,
effect: fn _ -> Task.async(fn -> Either.left(errors) end) end
}
end
defp merge_trace(base, traces, label) do
traces
|> Enum.reduce(base, &Effect.Context.merge/2)
|> Effect.Context.promote_trace(label)
end
@doc """
Validates a value using one or more validator functions, each returning an `Effect`.
If all validators succeed (`Right`), the original value is returned in a `Right`.
If any validator fails (`Left`), all errors are accumulated and returned as a single `Left`.
This function also manages telemetry trace context across all nested validations,
ensuring that span relationships and trace IDs are preserved throughout.
Supports optional `opts` for span metadata (e.g. `:span_name`).
## Examples
iex> validate_positive = fn x ->
...> Funx.Monad.Effect.lift_predicate(x, fn n -> n > 0 end, fn n -> "Value " <> Integer.to_string(n) <> " must be positive" end)
...> end
iex> validate_even = fn x ->
...> Funx.Monad.Effect.lift_predicate(x, fn n -> rem(n, 2) == 0 end, fn n -> "Value " <> Integer.to_string(n) <> " must be even" end)
...> end
iex> validators = [validate_positive, validate_even]
iex> result = Funx.Monad.Effect.validate(4, validators)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Right{right: 4}
iex> result = Funx.Monad.Effect.validate(3, validators)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Left{left: ["Value 3 must be even"]}
iex> result = Funx.Monad.Effect.validate(-3, validators)
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Left{left: ["Value -3 must be positive", "Value -3 must be even"]}
"""
@spec validate(
value,
(value -> t(error, any)) | [(value -> t(error, any))],
Effect.Context.opts_or_context()
) ::
t([error], value)
when error: term(), value: term()
def validate(value, validator, opts \\ [])
def validate(value, validators, opts) when is_list(validators) do
traverse_a(validators, fn v -> v.(value) end, opts)
|> map(fn _ -> value end)
end
def validate(value, validator, opts) when is_function(validator, 1) do
validate(value, [validator], opts)
end
@doc """
Converts an Elixir `{:ok, value}` or `{:error, reason}` tuple into an `Effect`.
Accepts an optional context context which includes telemetry tracking.
## Examples
iex> result = Funx.Monad.Effect.from_result({:ok, 42})
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Right{right: 42}
iex> result = Funx.Monad.Effect.from_result({:error, "error"})
iex> Funx.Monad.Effect.run(result)
%Funx.Monad.Either.Left{left: "error"}
"""
@spec from_result({:ok, right} | {:error, left}, Effect.Context.opts_or_context()) ::
t(left, right)
when left: term(), right: term()
def from_result(result, opts \\ []) do
case result do
{:ok, value} -> right(value, opts)
{:error, reason} -> left(reason, opts)
end
end
@doc """
Converts an `Effect` into an Elixir `{:ok, _}` or `{:error, _}` tuple by running the effect.
If the effect completes successfully (`Right`), the result is wrapped in `{:ok, value}`.
If the effect fails (`Left`), the error is returned as `{:error, reason}`.
This function also emits telemetry via `run/2` and supports optional context metadata through keyword options.
## Options
* `:span_name` – sets a custom span name for tracing and telemetry.
## Examples
iex> effect = Funx.Monad.Effect.right(42, span_name: "convert-ok")
iex> Funx.Monad.Effect.to_result(effect, span_name: "to_result")
{:ok, 42}
iex> error = Funx.Monad.Effect.left("fail", span_name: "convert-error")
iex> Funx.Monad.Effect.to_result(error, span_name: "to_result")
{:error, "fail"}
Telemetry will include the promoted span name (`"to_result -> convert-ok"`) and context metadata.
"""
@spec to_result(t(left, right), keyword()) :: {:ok, right} | {:error, left}
when left: term(), right: term()
def to_result(effect, opts \\ []) do
case run(effect, opts) do
%Either.Right{right: value} -> {:ok, value}
%Either.Left{left: reason} -> {:error, reason}
end
end
@doc """
Lifts a potentially exception-raising function into a Kleisli function for the `Effect` monad.
This returns a function of type (`input -> Effect`) that applies the given function to a value.
If the function raises, the error is captured and returned in a `Left`. You can optionally
provide a context (or opts) for tracing and telemetry.
## Examples
iex> safe_div = Funx.Monad.Effect.from_try(fn x -> 10 / x end)
iex> effect = Funx.Monad.Effect.pure(2) |> Funx.Monad.bind(safe_div)
iex> Funx.Monad.Effect.run(effect)
%Funx.Monad.Either.Right{right: 5.0}
iex> bad_div = Funx.Monad.Effect.pure(0) |> Funx.Monad.bind(safe_div)
iex> Funx.Monad.Effect.run(bad_div)
%Funx.Monad.Either.Left{left: %ArithmeticError{}}
"""
@spec from_try((input -> right), Effect.Context.opts_or_context()) ::
(input -> t(Exception.t(), right))
when input: term(), right: term()
def from_try(func, opts_or_context \\ []) when is_function(func, 1) do
context =
case opts_or_context do
%Effect.Context{} = ctx -> ctx
opts when is_list(opts) -> Effect.Context.new(opts)
end
fn value ->
%Right{
context: context,
effect: fn _env ->
Task.async(fn ->
Either.from_try(fn -> func.(value) end)
end)
end
}
end
end
@doc """
Executes an `Effect` and returns the result if it is a `Right`. If the result is a `Left`,
this function raises the contained error.
This is useful when you want to interoperate with code that expects regular exceptions,
such as within test assertions or imperative pipelines.
Runs the effect with full telemetry tracing.
## Examples
iex> effect = Funx.Monad.Effect.right(42, span_name: "return")
iex> Funx.Monad.Effect.to_try!(effect)
42
iex> error = Funx.Monad.Effect.left(%RuntimeError{message: "failure"}, span_name: "error")
iex> Funx.Monad.Effect.to_try!(error)
** (RuntimeError) failure
Telemetry will emit a `:stop` event with `:status` set to `:ok` or `:error`, depending on the outcome.
"""
@spec to_try!(t(left, right), keyword()) :: right | no_return
when left: term(), right: term()
def to_try!(effect, opts \\ []) do
effect
|> run(opts)
|> Either.to_try!()
end
end