Skuld.Comp (skuld v0.1.26)
View SourceSkuld.Comp: Evidence-passing algebraic effects with scoped handlers.
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
- ExternalSuspend: Sentinel struct that bypasses leave-scope
Auto-Lifting
Non-computation values are automatically lifted to pure(value). This enables
ergonomic patterns:
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)
endArchitecture
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 and exception handling.
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.
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.
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 and exception handling.
Raises a helpful error if the value is not a valid computation (2-arity function).
This catches common mistakes like forgetting return(value) at the end of a comp block.
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.
Similar to call/3 but for 3-arity 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
@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)
return(: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
@spec return(term()) :: Skuld.Comp.Types.computation()
Lift a pure value into a computation.
Alias for pure/1. Provided for ergonomic use both inside and outside
comp blocks. Inside comp blocks, use the imported return/1 from
Skuld.Comp.BaseOps. Outside comp blocks, use Comp.return/1 directly.
Example
# Inside a comp block (return is imported)
comp do
x <- State.get()
return(x + 1)
end
# Outside a comp block
fn x -> Comp.return(x * 2) end
@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)
return(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
return({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
)