Getting Started
View Source< What Skuld Solves | Up: Introduction | Index | Syntax In Depth >
This guide walks you through writing and running your first Skuld computation. By the end, you'll understand the basic workflow: define a computation, install handlers, and run it.
Setup
Add Skuld to your dependencies in mix.exs (see
Hex for the current version):
def deps do
[
{:skuld, "~> x.y"}
]
endIn any module that uses Skuld, add:
use Skuld.SyntaxThis imports the comp macro, defcomp, and the core aliases you'll
need (Skuld.Comp, etc.).
Your first computation
A computation is a lazy description of effectful work. It doesn't execute when you define it - it's a value you can compose, wrap with handlers, and eventually run.
The comp macro creates computations:
computation = comp do
count <- State.get()
_ <- State.put(count + 1)
count
endThis computation, when run, will:
- Get the current state value and bind it to
count - Update the state to
count + 1 - Return the original
countvalue
But nothing has happened yet. computation is inert until you run it.
Effectful binds and pure matches
Inside a comp block, there are two kinds of binding:
<- (effectful bind) runs an effect and binds its result:
count <- State.get() # run the State.get effect, bind result
name <- Reader.ask() # run the Reader.ask effect, bind result= (pure match) is ordinary Elixir pattern matching:
%{name: name} = user # pure destructuring, no effect
total = price * quantity # pure calculationThe difference matters: <- sequences effectful operations (each one
runs after the previous completes), while = is just regular pattern
matching on values you already have.
Auto-lifting
The last expression in a comp block is automatically lifted into a
computation. You don't need an explicit return or Comp.pure:
comp do
x <- State.get()
x * 2 # automatically becomes Comp.pure(x * 2)
endThis also means if without else works naturally:
comp do
_ <- if should_log?, do: Writer.tell("processing")
# nil auto-lifted to Comp.pure(nil) when should_log? is false
:done
endAny plain (non-computation) value is auto-lifted wherever Skuld expects a computation.
Installing handlers
Effects don't do anything by themselves - they need handlers. A handler tells the effect system how to respond to effect requests.
Install handlers by piping:
computation
|> State.with_handler(0) # State starts at 0
|> Comp.run!()State.with_handler(0) wraps the computation with a State handler whose
initial value is 0. The computation is still inert after this - adding
a handler just adds a wrapper.
Running
Comp.run!/1 executes a computation and returns the result:
comp do
count <- State.get()
_ <- State.put(count + 1)
count
end
|> State.with_handler(0)
|> Comp.run!()
#=> 0The State started at 0, so State.get() returned 0, which became
the return value. The state was updated to 1, but since we didn't ask
for it, it was discarded.
Comp.run/1 returns both the result and the final environment, which is
useful when working with suspended computations:
{result, env} = computation |> State.with_handler(0) |> Comp.run()Stacking handlers
A computation can use multiple effects. Install a handler for each one:
comp do
config <- Reader.ask()
count <- State.get()
_ <- State.put(count + 1)
_ <- Writer.tell("processed item #{count}")
{config, count}
end
|> Reader.with_handler(:my_config)
|> State.with_handler(0)
|> Writer.with_handler([])
|> Comp.run!()
#=> {{{:my_config, 0}, %{}}, []}The pipeline reads naturally: start with the computation, wrap with handlers, run. Handler order doesn't matter for correctness - each handler manages its own effect independently.
The output option
The result above is nested because each handler wraps the result. To get
cleaner output, use the :output option to transform the result when a
handler's scope ends:
comp do
config <- Reader.ask()
count <- State.get()
_ <- State.put(count + 1)
_ <- Writer.tell("processed item #{count}")
{config, count}
end
|> Reader.with_handler(:my_config)
|> State.with_handler(0, output: fn result, state -> {result, {:final_state, state}} end)
|> Writer.with_handler([], output: fn result, log -> {result, {:log, log}} end)
|> Comp.run!()
#=> {{{:my_config, 0}, {:final_state, 1}}, {:log, ["processed item 0"]}}The :output function receives (computation_result, handler_state) and
returns the transformed result. This lets you extract handler state
(like the final State value or the accumulated Writer log) alongside
the computation's return value.
Defining effectful functions with defcomp
Use defcomp to define named effectful functions:
defmodule Counter do
use Skuld.Syntax
defcomp increment() do
count <- State.get()
_ <- State.put(count + 1)
count + 1
end
defcomp increment_by(n) do
count <- State.get()
_ <- State.put(count + n)
count + n
end
defcomp increment_twice() do
_ <- increment()
increment()
end
enddefcomp wraps the function body in a comp block. The function returns
a computation that can be composed with other computations, wrapped with
handlers, and run.
Effectful functions compose naturally - increment_twice/0 calls
increment/0 twice using <-, and each call sequences properly.
A complete example
Here's an end-to-end example showing the same computation running with different handlers:
defmodule TodoService do
use Skuld.Syntax
alias Skuld.Effects.{State, Reader, Writer}
# A todo item
defmodule Todo do
defstruct [:id, :title, :done]
end
# Add a todo to the list
defcomp add_todo(title) do
todos <- State.get()
id <- Reader.ask() # use Reader to provide the next ID
todo = %Todo{id: id, title: title, done: false}
_ <- State.put([todo | todos])
_ <- Writer.tell("Added: #{title}")
todo
end
# Count completed todos
defcomp count_done() do
todos <- State.get()
Enum.count(todos, & &1.done)
end
endRunning with "production-style" handlers:
alias Skuld.Comp
TodoService.add_todo("Write docs")
|> Reader.with_handler(1) # ID = 1
|> State.with_handler([],
output: fn result, todos -> {result, todos} end)
|> Writer.with_handler([],
output: fn result, log -> {result, log} end)
|> Comp.run!()
#=> {{%Todo{id: 1, title: "Write docs", done: false},
# [%Todo{id: 1, title: "Write docs", done: false}]},
# ["Added: Write docs"]}Running the same code with different values:
TodoService.add_todo("Ship it")
|> Reader.with_handler(42) # different ID
|> State.with_handler(existing_todos,
output: fn result, todos -> {result, todos} end)
|> Writer.with_handler([],
output: fn result, log -> {result, log} end)
|> Comp.run!()Same function, different handlers, different behaviour. The
TodoService module has no idea where its IDs come from, how state is
stored, or what happens to its log messages. It just describes what it
needs using effects.
What's next
- Syntax In Depth - The
elseclause for match failures, thecatchclause for intercepting effects, clause grouping, anddefcompdetails - Foundational Effects - State, Reader, Writer, error handling, persistence, and more
- Advanced Effects - Coroutines, fibers, streaming, and serializable computations
< What Skuld Solves | Up: Introduction | Index | Syntax In Depth >