Skuld.Comp (skuld v0.28.0)
View SourceSkuld.Comp: Evidence-passing algebraic effects with scoped handlers.
Auto-Lifting
Non-computation values are automatically lifted to pure(value) — you almost
never need to call Comp.pure/1 explicitly. This enables ergonomic patterns
where bare values and expressions Just Work:
comp do
x <- State.get()
_ <- if x > 5, do: Writer.tell(:big) # nil auto-lifted when false
x * 2 # final expression auto-lifted (no `return` needed)
endAuto-lifting is implemented by the catch-all clause of call/3, which treats
any value that isn't a 2-arity function as pure(value) and passes it
directly to the continuation.
Core Concepts
- Computation:
(env, k -> {result, env})- a suspended computation - Result: Opaque value - framework doesn't impose shape
- Leave-scope: Continuation chain for scope cleanup/control
- ISentinel: Protocol that dispatches terminal handling at the run boundary.
Each sentinel type (Throw, ExternalSuspend, InternalSuspend, Cancelled)
has its own
ISentinel.run/2implementation. Comp.run is a cleancall + ISentinel.runpipeline with no sentinel-specific logic.
Architecture
Unlike Freyja's centralised interpreter, Skuld uses decentralised evidence-passing. Run acts as a control authority - it recognizes the ExternalSuspend sentinel and invokes the leave-scope chain - but treats results as opaque.
Scoped effects (Reader.local, Catch) install leave-scope handlers that can clean up env or redirect control flow.
Summary
Functions
Sequence computations
Call a computation with validation, exception handling, and auto-lifting.
Call an effect handler with exception handling.
Call a continuation (k or leave_scope) with exception handling.
Cancel a suspended computation, invoking the leave_scope chain for cleanup.
Check whether a value is a computation.
Apply f to each element for side effects, discarding results.
Invoke an effect operation
Flatten nested computations
identity continuation - for initial continuation & default leave-scope
Map over a computation's result
Lift a pure value into a computation.
Lift a pure value into a computation. Alias for pure/1.
Run a computation to completion.
Run a computation, extracting just the value (raises on ExternalSuspend/Throw)
Create a scoped computation with a final continuation for cleanup and result transformation.
Sequence a list of computations.
Sequence computations, ignoring first result
Apply f to each element, sequence the resulting computations.
Install a scoped handler for an effect.
Install scoped state for a computation with automatic save/restore.
Functions
@spec bind(Skuld.Comp.Types.computation(), (term() -> Skuld.Comp.Types.computation())) :: Skuld.Comp.Types.computation()
Sequence computations
@spec call( Skuld.Comp.Types.computation(), Skuld.Comp.Types.env(), Skuld.Comp.Types.k() ) :: {Skuld.Comp.Types.result(), Skuld.Comp.Types.env()}
Call a computation with validation, exception handling, and auto-lifting.
If the value is a 2-arity function, it's called as comp.(env, k).
If the value is not a computation (not a 2-arity function), it is automatically
lifted — treated as pure(value) and passed directly to the continuation.
This enables ergonomic patterns without explicit wrapping:
_ <- if condition, do: Writer.tell(x) # nil auto-lifted when false
x + 1 # final expression auto-lifted (no return needed)Elixir exceptions (raise/throw/exit) are caught and converted to Throw effects,
allowing them to be handled uniformly with effect-based errors via catch_error.
Note: InvalidComputation errors (validation failures) are re-raised rather than
converted to Throws, since they represent programming bugs that should fail fast.
@spec call_handler( Skuld.Comp.Types.handler(), term(), Skuld.Comp.Types.env(), Skuld.Comp.Types.k() ) :: {Skuld.Comp.Types.result(), Skuld.Comp.Types.env()}
Call an effect handler with exception handling.
Supports both 2-arity total+linear handlers and 3-arity general handlers. Exceptions in handler code are caught and converted to Throw effects.
@spec call_k(Skuld.Comp.Types.k(), term(), Skuld.Comp.Types.env()) :: {Skuld.Comp.Types.result(), Skuld.Comp.Types.env()}
Call a continuation (k or leave_scope) with exception handling.
Continuations have signature (value, env) -> {value, env}. Unlike call/3
which handles computations, this handles the simpler continuation case where
we just need to catch Elixir exceptions and convert them to Throw effects.
Used in scoped/2 to wrap calls to finally_k.
@spec cancel(Skuld.Comp.ExternalSuspend.t(), Skuld.Comp.Types.env(), term()) :: {Skuld.Comp.Cancelled.t(), Skuld.Comp.Types.env()}
Cancel a suspended computation, invoking the leave_scope chain for cleanup.
When a computation yields (returns %ExternalSuspend{}), the caller can either:
- Resume it with
suspend.resume.(input) - Cancel it with
Comp.cancel(suspend, env, reason)
Cancellation creates a %Cancelled{reason: reason} result and invokes the
leave_scope chain, allowing effects to clean up resources.
Example
# Run until suspension
{%ExternalSuspend{} = suspend, env} = Comp.run(my_yielding_comp)
# Decide to cancel instead of resume
{%Cancelled{reason: :timeout}, final_env} =
Comp.cancel(suspend, env, :timeout)Effect Cleanup
Effects can detect cancellation in their leave_scope handlers:
my_leave_scope = fn result, env ->
case result do
%Cancelled{} -> cleanup_my_resources(env)
_ -> :ok
end
{result, env}
end
Check whether a value is a computation.
A computation in Skuld is a 2-arity function (env, k) -> {result, env}.
This is a runtime heuristic — any 2-arity function will return true. In
contexts where precision matters (e.g., stream combinators), prefer an
explicit tagged return value.
@spec each(list(), (term() -> Skuld.Comp.Types.computation())) :: Skuld.Comp.Types.computation()
Apply f to each element for side effects, discarding results.
Like traverse/2 but returns :ok instead of collecting results.
Useful when you only care about effects (e.g., Writer.tell), not values.
Example
comp do
_ <- Comp.each(items, &Writer.tell/1)
:done
end
@spec effect(Skuld.Comp.Types.sig(), term()) :: Skuld.Comp.Types.computation()
Invoke an effect operation
@spec flatten(Skuld.Comp.Types.computation()) :: Skuld.Comp.Types.computation()
Flatten nested computations
identity continuation - for initial continuation & default leave-scope
@spec map(Skuld.Comp.Types.computation(), (term() -> term())) :: Skuld.Comp.Types.computation()
Map over a computation's result
@spec pure(term()) :: Skuld.Comp.Types.computation()
Lift a pure value into a computation.
You almost never need this — bare values are automatically lifted by call/3.
Prefer returning values directly inside comp blocks rather than wrapping
them with pure/1.
pure/1 is still useful when you need an explicit computation value for
combinators like map/2, sequence/1, or when passing computations as
arguments.
@spec return(term()) :: Skuld.Comp.Types.computation()
Lift a pure value into a computation. Alias for pure/1.
Note: auto-lifting makes this unnecessary in almost all contexts.
Prefer bare values — they're automatically lifted via call/3.
@spec run(Skuld.Comp.Types.computation()) :: {Skuld.Comp.Types.result(), Skuld.Comp.Types.env()}
Run a computation to completion.
Creates a fresh environment internally — all handler installation should
be done via with_handler on the computation.
Uses ISentinel protocol to determine completion behavior:
- ExternalSuspend: bypasses leave-scope chain
- Other values: invoke leave-scope chain
Example
{result, _env} =
my_comp
|> State.with_handler(0)
|> Reader.with_handler(:config)
|> Comp.run()
@spec run!(Skuld.Comp.Types.computation()) :: term()
Run a computation, extracting just the value (raises on ExternalSuspend/Throw)
@spec scoped( Skuld.Comp.Types.computation(), (Skuld.Comp.Types.env() -> {Skuld.Comp.Types.env(), Skuld.Comp.Types.leave_scope()}) ) :: Skuld.Comp.Types.computation()
Create a scoped computation with a final continuation for cleanup and result transformation.
The setup function receives the current env and must return
{modified_env, finally_k} where finally_k :: (value, env) -> {value, env}
is a continuation that runs when the scope exits.
This enables Koka-style with semantics where handlers can transform
computation results (e.g., wrapping with collected state, logs, etc.).
The finally_k continuation is called on both:
- Normal exit: before continuing to outer computation
- Abnormal exit: during leave-scope unwinding (e.g., throw)
The previous leave-scope is automatically restored in both paths.
The argument order is pipe-friendly (computation first).
Example - Environment restoration only
def local(modify, comp) do
comp
|> Skuld.Comp.scoped(fn env ->
current = Env.get_state(env, @sig)
modified_env = Env.put_state(env, @sig, modify.(current))
finally_k = fn value, e -> {value, Env.put_state(e, @sig, current)} end
{modified_env, finally_k}
end)
endExample - Result transformation (like EffectLogger)
def with_logging(comp) do
comp
|> Skuld.Comp.scoped(fn env ->
env_with_log = Env.put_state(env, :log, [])
finally_k = fn value, e ->
log = Env.get_state(e, :log)
cleaned = Map.delete(e.state, :log)
{{value, Enum.reverse(log)}, %{e | state: cleaned}}
end
{env_with_log, finally_k}
end)
end
@spec sequence([Skuld.Comp.Types.computation()]) :: Skuld.Comp.Types.computation()
Sequence a list of computations.
Runs each computation in order, collecting results into a list. Uses a tail-recursive accumulator to avoid stack overflow on large lists.
@spec then_do(Skuld.Comp.Types.computation(), Skuld.Comp.Types.computation()) :: Skuld.Comp.Types.computation()
Sequence computations, ignoring first result
@spec traverse(list(), (term() -> Skuld.Comp.Types.computation())) :: Skuld.Comp.Types.computation()
Apply f to each element, sequence the resulting computations.
Uses a tail-recursive accumulator to avoid stack overflow on large lists.
@spec with_handler( Skuld.Comp.Types.computation(), Skuld.Comp.Types.sig(), Skuld.Comp.Types.handler() ) :: Skuld.Comp.Types.computation()
Install a scoped handler for an effect.
The handler is installed for the duration of comp and then restored
to its previous state (or removed if there was no previous handler).
This allows "shadowing" handlers - an inner computation can have its own handler for an effect while an outer handler exists.
The argument order is pipe-friendly (computation first).
Example
# Create a computation with its own State handler
inner =
comp do
x <- State.get()
_ <- State.put(x + 1)
x
end
|> Comp.with_handler(State, &State.handle/3)
# Use it - inner State is independent of outer State
outer = comp do
_ <- State.put(100)
result <- inner # uses inner's handler
y <- State.get() # uses outer's handler, still 100
{result, y}
end
@spec with_scoped_state(Skuld.Comp.Types.computation(), term(), term(), keyword()) :: Skuld.Comp.Types.computation()
Install scoped state for a computation with automatic save/restore.
This is a common pattern used by effect handlers to manage state that should be isolated to a computation scope. On entry, saves previous state (if any) and sets initial state. On exit (normal or throw), restores previous state or removes it if there was none.
Options
:output- optional function(result, final_state) -> new_resultto transform the result using the final state value before returning.:suspend- optional function(ExternalSuspend.t(), env) -> {ExternalSuspend.t(), env}to decorate ExternalSuspend values when yielding. Allows attaching scoped state to suspends.:default- default value when reading final state (default: nil)
Example
# Simple usage - state is saved/restored automatically
comp
|> Comp.with_scoped_state(state_key, initial_value)
|> Comp.with_handler(sig, handler)
# With output transformation - include final state in result
comp
|> Comp.with_scoped_state(state_key, initial, output: fn result, final -> {result, final} end)
|> Comp.with_handler(sig, handler)
# With suspend decoration - attach state to ExternalSuspend.data when yielding
comp
|> Comp.with_scoped_state(state_key, initial,
suspend: fn s, env ->
state = Env.get_state(env, state_key)
data = s.data || %{}
{%{s | data: Map.put(data, :my_state, state)}, env}
end
)