Freyja
Installation
Add freyja to your list of dependencies in mix.exs:
def deps do
[
{:freyja, "~> 0.1.1"}
]
endWhat is Freyja?
Freyja is an Algebraic Effects system for Elixir, enabling you to write programs as pure functions that describe all their side effects as "effect" data structures. These effects are then interpreted by handlers, providing a clean separation between what your program does (the effects) and how it does it (the handlers).
0. tl;dr
Algebraic Effects and Handlers for Elixir. Both first-order and higher-order
effects are supported. A with-like syntax is introduced to help with
sequencing computations:
defhefty process_order(order_id) do
# First-order effects are auto-lifted in hefty blocks.
# Match failures will go to else
%{price: order_price} = order <- EctoFx.query(Queries, :find_order, %{id: order_id})
# ordinary = assignment & matching with else clause for failures
{:ok, validated} = validate_order(order)
# More effects - state tracking
total_value <- State.get()
new_total_value = total_value + order_price
_ <- State.put(new_total_value)
# Higher-order effect: transaction wrapping
result <- EctoFx.transaction(
hefty do
_ <- EctoFx.update(Order.confirm_changeset(validated))
_ <- EctoFx.insert(AuditLog.changeset(%{order_id: order_id, action: "confirmed"}))
return({:confirmed, validated})
end
)
return(result)
else
# Handle pattern match failures (e.g., validate_order returned {:error, _})
{:error, :invalid_items} -> return({:rejected, :invalid_items})
{:error, reason} -> return({:rejected, reason})
catch
# Handle thrown errors (e.g., from EctoFx operations)
:db_connection_error -> return({:error, :database_unavailable})
thrown -> return({:error, thrown})
end1. What are Algebraic Effects?
Algebraic effects are plain data structures that describe something impure you want your program to do. Instead of performing I/O, mutating state, or throwing errors directly, a function can return an effect value such as “read the current state” or “write this log message”.
An algebraic effect system such as Freyja lets you build programs whose domain logic lives entirely in pure functions that emit these effect values. Separate handlers interpret the emitted data structures and decide how (or whether) to carry out the effects.
This separation has several benefits:
- Composability – swap or stack handlers to change behavior (e.g., real DB vs. in-memory mock).
- Testability – pure functions are easy to unit test; handlers can log or replay effects deterministically.
- Replay & Debugging – since effects are first-class data, they can be logged, serialized, and replayed later, even on a different machine.
In short: describe your intentions as data, keep your business logic pure, and let Freyja orchestrate how and when effects run.
Further reading:“What is Algebraic about Algebraic Effects?” offers a gentle introduction to why they are called Algebraic Effects.
1.1 A real effect: Tagged State
Freyja is bundled with a number of Effects and Handlers - TaggedState is one
of them - it gives access to "apparently mutable" (not really mutable!)
state cells - from anywhere inside a nested stack of pure functions, without
having to add any extra parameters to function signatures
TaggedState has a signature module Freyja.Effects.TaggedState, which
defines some structs which represent the "operations" the effect supports,
%TaggedState.Get{tag: <tag>} and %TaggedState.put{tag: <tag>, val: <val>},
and some "constructor functions" &TaggedState.get/1 and &TaggedState.put/2
# TaggedState: get/put state associated with a tag
defmodule Freyja.Effects.TaggedState do
alias Freyja.Freer
# Effect structs - plain data describing operations
defmodule GetTagged do
defstruct [:tag]
end
defmodule PutTagged do
defstruct [:tag, :val]
end
# Constructor functions wrap structs in Freer.Impure via send_effect
def get(tag), do: %GetTagged{tag: tag} |> Freer.send_effect()
def put(tag, val), do: %PutTagged{tag: tag, val: val} |> Freer.send_effect()
end
The constructor functions like get(tag) and put(tag, val) build effect
operation structs and wrap them in a minimal Freer.Impure structure using
Freer.send_effect/1. This Impure struct is what gets interpreted by
Handlers - see section 3.2 for more details on how send_effect and
Impure work.
Remember that the effect structs themselves are just simple data - they are used to signal that a computation wants to do something, but they neither do anything nor say how a thing should be done.
%Freyja.Effects.TaggedState.GetTagged{tag: :cart}
%Freyja.Effects.TaggedState.PutTagged{tag: :cart, val: [:item_a, :item_b]}Handlers decide exactly what to do with them — read from ETS, append to a log, store in a map, or something else entirely.
1.2 Define Your Own Effect Language
Most applications invent their own "impure verbs". With Freyja you can codify those verbs as effect structs instead of performing side effects immediately.
# Domain-specific storage effect
defmodule MyApp.Storage do
alias Freyja.Freer
defmodule Query do
defstruct [:table, :id]
end
defmodule Change do
defstruct [:table, :record]
end
# Constructor functions use send_effect to wrap structs in Freer.Impure
def query(table, id), do: %Query{table: table, id: id} |> Freer.send_effect()
def change(table, record), do: %Change{table: table, record: record} |> Freer.send_effect()
end
# Domain-specific notification effect
defmodule MyApp.Notifications do
alias Freyja.Freer
defmodule SendPush do
defstruct [:user_id, :message]
end
def send_push(user_id, message), do: %SendPush{user_id: user_id, message: message} |> Freer.send_effect()
end
Your pure business logic can now "describe" what it needs. The con macro
helps you compose (first-order) effectful computations using a familiar
with-like syntax:
def checkout(cart, user) do
con do
product <- MyApp.Storage.query(:products, cart.product_id)
if user.credit < product.price do
Throw.throw_error(:insufficient_credit)
else
con do
updated_user = %{user | credit: user.credit - product.price}
_ <- MyApp.Storage.change(:users, updated_user)
_ <- MyApp.Notifications.send_push(user.id, "Thanks for buying #{product.name}!")
return({:ok, updated_user})
end
end
end
end
At this point, checkout/2 is an entirely pure function —it only has pure
domain logic and emits effect structs, while handlers will decide how to
interpret them: hitting real services, wrapping DB access in transactions,
or using mocks in tests.
case checkout(cart, user)
|> MyApp.Storage.PostgreSQLHandler.run(db_connection)
|> MyApp.Notifications.PigeonHandler.run(push_adapter)
|> Throw.Handler.run()
# eval returns only the result - run will return the full context
|> Run.eval() do
{:ok, updated_user} ->
IO.inspect(updated_user, label: "User debited")
{:error, :insufficient_credit} ->
Logger.warn("Not enough credit")
{:error, reason} ->
Logger.error("Checkout failed: #{inspect(reason)}")
endThis illustrates how Freyja lets your domain logic stay pure while the handlers deal with the impure plumbing.
2. A Quick Tour: A short list of some cool things Algebraic Effects enable
Not nearly an exhaustive list, but there are IEx runnable examples for each case!
2.1 EctoFx: Taming dataabase interactions
The ecto_user_service.ex
example shows how to build domain services that use Ecto effects for queries and
mutations, while keeping domain logic completely testable without a database.
The Problem: Traditional Ecto code tightly couples domain logic to the database:
def create_user_with_profile(attrs) do
Repo.transaction(fn ->
user = Repo.insert!(User.changeset(attrs))
profile = Repo.insert!(Profile.changeset(user, attrs))
{user, profile}
end)
endThis is hard to test without a database. With EctoFx effects:
defhefty register_user(attrs) do
# Check if email already exists
existing <- EctoFx.query(Queries, :find_user_by_email, %{email: attrs.email})
result <-
case existing do
nil ->
# Email not taken - create user and profile in transaction
EctoFx.transaction(
hefty do
user <- EctoFx.insert(User.changeset(attrs))
profile <- EctoFx.insert(Profile.changeset(user, attrs))
return({user, profile})
end
)
_user ->
# Email already taken - return error via Throw
Throw.throw_error({:email_taken, attrs.email})
end
return(result)
endIn tests - no database needed! Use EctoFx.TestHandler with stubbed queries:
state =
EctoFx.TestHandler.new()
|> EctoFx.TestHandler.stub_query(Queries, :find_user_by_email, %{email: "alice@test.com"}, nil)
outcome =
EctoUserService.register_user(%{name: "Alice", email: "alice@test.com"})
|> EctoFx.TestHandler.run(state)
|> Lift.Algebra.run()
|> Throw.Handler.run()
|> Run.run()
assert {:ok, {%User{name: "Alice"}, %Profile{}}} = outcome.resultIn production - real database with EctoFx.Handler:
outcome =
EctoUserService.register_user(%{name: "Alice", email: "alice@example.com"})
|> EctoFx.Handler.run(MyApp.Repo, %{Queries => :direct})
|> Lift.Algebra.run()
|> Throw.Handler.run()
|> Run.run()Benefits:
- Domain logic stays pure and testable
- Test handler automatically applies changeset changes, validating your logic
- Same code works with real DB or test stubs
- Transactions compose naturally with other effects
2.2 Coroutine-Based Programming
From the IEx runnable command_processor.ex
example:
A Coroutine effect let you suspend and resume computations. Domain logic can be completely agnostic about how responses are gathered—interactive UI, CLI prompts, LLMs, or batch pipelines can all drive the same pure core.
Since effects are just simple data-structures you can use your effects as commands - and your whole system becomes command-driven with little effort.
Here's a simple coroutine-based command processor which repeatedly suspends, asking for the next command. You can feed it commands from a UI or CLI or, since your commands are just easily documented strucs, you can have an LLM build commands and AI enable your whole app for free:
defcon loop do
# yield to outside the computation to ask for the next command
command <- Coroutine.yield(:next_command)
case command do
%Storage.Query{} = effect ->
handle_effect(effect)
%Storage.Change{} = effect ->
handle_effect(effect)
%Notifications.SendPush{} = effect ->
handle_effect(effect)
:stop ->
return(:stopped)
other ->
Throw.throw_error({:unknown_command, other})
end
end
defconp handle_effect(effect) do
_ <- effect
loop()
end
# provide handlers for all the effects
builder = Freyja.Examples.CommandProcessor.builder()
# run the computation up to the yield
processor = Freyja.Run.run(builder)
commands = [
Storage.query(:products, "A1"),
Storage.change(:users, %{id: 1, name: "Ann"}),
Notifications.send_push(1, "Hello!"),
:stop
]
# repeatedly resume the computation with successive commands/effects
final_outcome = Enum.reduce(commands, processor, fn cmd, outcome ->
Freyja.Run.resume(builder, outcome, cmd)
end)
Because commands are just effect structs, you can whitelist them for MCP tooling, log them, or feed them manually—no extra glue code required.
2.3 EffectLogger: Log, Replay, and Resume Anything
(a) Automatic Log Collection
By inserting EffectLogger.Handler.run/1 at the start of the Handler
pipeline, you get full logs of every effect emitted—perfect for audit,
tracing, or offline debugging.
outcome =
con do
config <- TaggedReader.ask(:config)
starting <- State.get()
updated = starting + config
_ <- State.put(updated)
return(updated)
end
|> EffectLogger.Handler.run(EffectLogger.Log.new())
|> TaggedReader.Handler.run(%{config: 32})
|> State.Handler.run(10)
|> Run.run()
outcome.result # => 42
IO.inspect(outcome, pretty: true) # output belowExample output (abridged):
%Freyja.Run.RunOutcome{
result: 42,
outputs: %{
Freyja.Effects.TaggedReader.Handler => %{config: 32},
Freyja.Effects.EffectLogger.Handler => %Freyja.Effects.EffectLogger.Log{
stack: [],
queue: [
%Freyja.Effects.EffectLogger.StepLogEntry{
effects_stack: [],
effects_queue: [
%Freyja.Effects.EffectLogger.EffectLogEntry{
sig: Freyja.Effects.TaggedReader,
data: %Freyja.Effects.TaggedReader.AskTagged{tag: :config}
}
],
completed?: true,
value: 32
},
%Freyja.Effects.EffectLogger.StepLogEntry{
effects_stack: [],
effects_queue: [
%Freyja.Effects.EffectLogger.EffectLogEntry{
sig: Freyja.Effects.State,
data: %Freyja.Effects.State.Get{}
}
],
completed?: true,
value: 10
},
%Freyja.Effects.EffectLogger.StepLogEntry{
effects_stack: [],
effects_queue: [
%Freyja.Effects.EffectLogger.EffectLogEntry{
sig: Freyja.Effects.State,
data: %Freyja.Effects.State.Put{val: 42}
}
],
completed?: true,
value: 10
}
],
allow_divergence?: false
},
Freyja.Effects.State.Handler => 42
},
}(b) Rerun to Debug (Even After Serialization)
builder =
computation
|> EffectLogger.Handler.run(log)
|> State.Handler.run(0)
outcome = builder |> Run.run()
# Later: fix the code and rerun using the captured log
json = Jason.encode!(outcome)
decoded = Jason.decode!(json)
debug_outcome = Run.rerun(builder, decoded)Run.rerun/2 will "run" a computation from "cold" logs (after a JSON
serialization/deserialization roundtrip). Until the final
step rerun doesn't really run anything other than the pure domain code -
it supplies logged effect values to each step of the computation,
so every step gets the exact same data that was logged during the
failed computation run. At the final step (signalled by the
:allow_divergence? flag in the Log), where an error may
have been raised, it switches back to "new computation" mode and
handles the effect normally, allowing bugfixed code to continue normally after
the error.
you can try it out in IEx with:
Freyja.Examples.EffectLoggerRerun:
buggy = Freyja.Examples.EffectLoggerRerun.build(:original)
buggy_outcome = buggy |> Freyja.Run.run()
buggy_outcome.result # => {:error, :validation_failed}
json = buggy_outcome |> Jason.encode!()
fixed = Freyja.Examples.EffectLoggerRerun.build(:patched)
fixed_outcome = Freyja.Run.rerun(fixed, Jason.decode!(json))
fixed_outcome.result # => {:ok, :ok}(c) Cold Resume from Logs
builder = Freyja.Examples.EffectLoggerResume.build()
outcome = builder |> Freyja.Run.run()
{:suspend, prompt, _} = outcome.result
checkpoint = Jason.encode!(outcome)
# Later
decoded_checkpoint = Jason.decode!(checkpoint)
resumed = Freyja.Run.resume(builder, decoded_checkpoint, :new_value)
resumed.result # => {:done, :new_value}
EffectLogger’s serialized state is also enough to "cold" resume a coroutine from
deserialized logs, even though the original continuation has been lost! See
Freyja.Examples.EffectLoggerResume
for a copy/pasteable builder demonstrating the pattern in IEx.
2.4 Change Capture with EctoFx
The ecto_change_capture.ex
example demonstrates capturing intended database changes without immediately
persisting them - enabling batch operations, dry-run mode, and audit logging.
The Pattern: Write simple per-record processing functions that use
EctoFx.Changes to record changes, then use EctoFx.capture/1 to collect
them without persisting:
# Simple per-record processing function
defhefty anonymize_user(user) do
changeset = User.anonymize_changeset(user)
# Record the change (captured, not persisted)
_ <- EctoFx.Changes.update(changeset)
# Also record an audit log entry
audit_changeset = AuditLog.changeset(%{
user_id: user.id,
action: "anonymize",
details: %{original_email: user.email}
})
_ <- EctoFx.Changes.insert(audit_changeset)
return(Ecto.Changeset.apply_changes(changeset))
end
# Capture changes from processing multiple users
defhefty anonymize_users_with_capture(user_ids) do
users <- EctoFx.query(Queries, :find_users_by_ids, %{ids: user_ids})
# EctoFx.capture/1 collects all EctoFx.change calls without persisting
{anonymized_users, captured_changes} <-
EctoFx.capture(FxList.fx_map(users, &anonymize_user/1))
return({anonymized_users, captured_changes})
end
The captured changes are returned as %{inserts: [...], updates: [...], deletes: [...]}
containing Ecto changesets. Apply them in bulk within a transaction:
defhefty transactional_anonymize(user_ids) do
EctoFx.transaction(
hefty do
users <- EctoFx.query(Queries, :find_users_by_ids, %{ids: user_ids})
# Capture all changes without persisting
{anonymized, changes} <-
EctoFx.capture(FxList.fx_map(users, &anonymize_user/1))
# Persist inserts in bulk (audit logs)
_ <- EctoFx.insert_all(AuditLog, EctoFx.to_entries(changes.inserts))
# Persist updates in bulk using upsert
_ <- EctoFx.insert_all(
User,
EctoFx.to_entries(changes.updates),
on_conflict: :replace_all,
conflict_target: [:id]
)
return({anonymized, changes})
end
)
endUse Cases:
- Batch processing: Process 1000 users individually, but INSERT/UPDATE in bulk
- Dry-run mode: Capture changes without applying them, show what would change
- Audit logging: Record exactly what changes were intended before applying
- Validation: Validate the entire batch before committing any changes
- Testing: Verify change logic without touching the database
2.5 TaggedReader: Stable Signatures When Requirements Change
The tagged_reader_dynamic_context.ex
example demonstrates how algebraic effects keep function signatures stable
when requirements change.
The Problem: In traditional code, adding context to a deep function requires changing every intermediate function's signature:
# Original
def generate_report(accounts), do: Enum.map(accounts, &summarize/1)
def summarize(account), do: %{name: account.name, spending: sum(account)}
# After requirements change - need greetings context
def generate_report(accounts, greetings), do: Enum.map(accounts, &summarize(&1, greetings))
def summarize(account, greetings), do: %{..., greeting: greetings[account.country]}The Solution: With TaggedReader, the deep function simply asks for what
it needs. No intermediate functions change:
# generate_report NEVER changes - works with any summarizer
defhefty generate_report(accounts, summarizer_fn) do
FxList.fx_map(accounts, summarizer_fn)
end
# Version 1: Simple summary
defhefty summarize_spending(account) do
total = sum_transactions(account.recent_transactions)
return(%{name: account.name, recent_spending: total})
end
# Version 2: Requirements change! Need greeting - just ASK for it
defhefty summarize_with_greeting(account) do
greetings <- TaggedReader.ask(:greetings)
total = sum_transactions(account.recent_transactions)
greeting = Map.get(greetings, account.country, "Hello!")
return(%{name: account.name, recent_spending: total, greeting: greeting})
endThe context is provided at handler configuration time, completely decoupled from the function call chain:
# Version 1 - no context needed
TaggedReaderDynamicContext.build_v1(accounts)
|> Run.run()
# Version 2 - greetings provided at handler level
greetings = %{"UK" => "Cheerio!", "US" => "Howdy!", "DE" => "Guten Tag!"}
TaggedReaderDynamicContext.build_v2(accounts, greetings)
|> Run.run()
# => [%{name: "Alice", recent_spending: 41.49, greeting: "Cheerio!"}, ...]Benefits:
- Stable signatures: Intermediate functions don't change when deep functions need more context
- Separation of concerns: Business logic doesn't know where context comes from
- Easy testing: Provide different context maps for different test scenarios
- Incremental extension: Add more
TaggedReader.askcalls as requirements evolve
3. How does it work
Let's look at a simple computation and develop an intuition for how it works:
con do
x <- State.get()
y <- Reader.ask()
return(x + y)
end
This computation has a series of "steps", which correspond to lines inside the
con block. We can read the steps as follows:
getthe currentStateand bind it to variablexasktheReaderfor its value, and bind it to variableyreturnx+yto the caller
This is the "surface" interpretation of what's happening - and it's a reasonable approximation, but it hides considerable detail. Here's a more detailed reading:
State.get()builds a simple%Get{}struct which is returned as the currentnon-terminalvalue of the computation to an interpreter, to ask theStateeffect for its value-
the interpreter identifies a
Handlerwhich can interpretStaterequests and calls it to get a value from somewhere - it could be anywhere at the discretion of theHandler- and the computation is resumed with that value which is bound immediately tox(xis in fact a function parameter - see section 3.1 for how this happens) Reader.ask()builds a simple%Ask{}struct which is returned as anon-terminalvalue to the interpreter, requesting the value from theReadereffect-
the interpreter identifies a
handlerwhich can interpretReaderrequests, calls it to get a value, and the computation is resumed again with that value, which is bound toy returnreturns theterminalvaluex+yto the interpreter, which seeing a terminal value returns to its caller with that value
The con block makes the "surface" interpretation easy, and that's
deliberate - it's an abstraction built to give a convenient mental building
block, but sometimes it's a good idea to understand the details, so in the
next couple of sections we'll look at how the computation is broken down into
steps, and those steps are exposed to an interpreter
3.1 The con and hefty Macros: Breaking down binds
The con and hefty macros provide a with-like syntax that rewrites to nested
bind calls. This is similar to Haskell's do notation.
Simple Rewrite Rules
The macros apply a simple transformation:
- Effect binding (
x <- effect()) becomes abindcall - Pure binding (
x = value) stays as a regular assignment - The last expression is returned as-is (it must be a
Freer.t()forcon, or aHefty.t()forhefty- so plain values should be wrapped withreturn(value)) - Functions with
conorheftybodies can be defined withdefcondefhefty
Example: con macro expansion
# Input: con block with effect bindings
con do
x <- State.get()
y <- Reader.ask()
return(x + y)
end
# Expands to: nested bind calls
State.get()
|> Freyja.Freer.bind(fn x ->
Reader.ask()
|> Freyja.Freer.bind(fn y ->
return(x + y)
end)
end)
Example: defcon macro expansion
The defcon macro defines a function with a con body. Pure = assignments
are preserved inline within the continuation:
# Input
defcon double_state() do
x <- State.get()
doubled = x * 2
_ <- State.put(doubled)
return(doubled)
end
# Expands to
def double_state() do
import Freyja.Freer.BaseOps
State.get()
|> Freyja.Freer.bind(fn x ->
doubled = x * 2
State.put(doubled)
|> Freyja.Freer.bind(fn _ ->
return(doubled)
end)
end)
end
Example: hefty macro expansion
The hefty macro works the same way but uses Hefty.bind instead:
# Input
defhefty anonymize_users_with_capture(user_ids) do
users <- EctoFx.query(Queries, :find_users_by_ids, %{ids: user_ids})
{results, changes} <- EctoFx.capture(FxList.fx_map(users, &anonymize_user/1))
return({results, changes})
end
# Expands to
def anonymize_users_with_capture(user_ids) do
import Freyja.Hefty, only: [return: 1]
EctoFx.query(Queries, :find_users_by_ids, %{ids: user_ids})
|> Freyja.Hefty.bind(fn users ->
EctoFx.capture(FxList.fx_map(users, &anonymize_user/1))
|> Freyja.Hefty.bind(fn {results, changes} ->
return({results, changes})
end)
end)
endKey Points
-
The right-hand side of
<-must return an effect computation (Freer.t()forcon,Hefty.t()forhefty) -
First-order effects (State, Reader, etc.) are auto-lifted in
heftyblocks via theIHeftySendableprotocol -
The
return(value)function wraps a value in a pure computation (Freer.pureorHefty.pure) -
Unlike
with, bindings happen inside thedoblock, not before it - this is due to limitations in the syntax expressible with Elixir macros (vs special forms likewith)
3.2 Freer - let's interpret some effects in IEx
Now we have a rough idea of how the computations in con and hefty blocks
can be understood, and how they are expanded into normal Elixir code,
let's develop that intuition further by manually performing the role of
the interpreter and the Handlers the interpreter calls to deal with
individual effects
We'll focus on first-order effects - they are effects which
do not contain other effects in their data-structure, and are simpler
to deal with - the con macro is used to expand first-order effects,
while the hefty macro is used to expand higher-order effects.
Freyja uses a type called Freer to capture steps in a computation. Freer
has two structs: There's Pure which wraps an ordinary-value, and
represents a terminal state of a computation, and Impure which
represents non-terminal states of a computation and holds:
sig- an identifier for the type of thedata- an "effect" moduledata- a piece of data which can be interpreted (by aHandler) to yield an ordinary-value - this is an "operation" of an "effect"q- a queue of continuations(ordinary_value -> Freer.t())being the remaining steps in the computation
defmodule Freer do
defmodule Pure do
defstruct val: nil
@type t :: %__MODULE__{
val: any
}
end
defmodule Impure do
defstruct sig: nil, data: nil, q: []
@type t :: %__MODULE__{
sig: atom,
data: any,
# should be list((any->freer))
q: list((any -> freer))
}
end
@type t() :: %Pure{} | %Impure{}
endThere are a few functions you will need to know about:
Freer.return(any) :: Freer.t()- a.k.aFreer.pure- wraps an ordinary value in a%Freer.Pure{}struct - this is how ordinary values from pure computations get "lifted" into something the interpreter can deal with, and how computations signal "we're done with this step" to the interpreterFreer.send_effect(any) :: Freer.t()- this wraps an effect operation struct (it can be any type, but structs are nicer so the convention is to use them) into a minimal%Freer.Impure{}struct, with just&Freer.pure/1in its continuation queue. RememberFreer.Impureis a non-terminal state of the computation, so it's saying to the interpreter "here's something you are going to need to interpret, by finding a Handler for"bind(Freer.t(), (any -> Freer.t())) :: Freer.t()-bindis how computations move forward from one step to the next. It takes aFreercomputation, extracts a value from it (the job of the interpreter) and gives that value to a "continuation" - which returns anotherFreer- a modified computation, now including the additional function of the next "step"
Let's look again at the expansion of the simple con block from above, and
by manually playing the role of the interpreter and Handlers see how
the computation gets represented as Freer and how the non-terminal
Impure structs get repeatedly interpreted until there is only
a terminal Pure struct.
con do
x <- State.get()
y <- Reader.ask()
return(x + y)
endand its expansion:
State.get()
|> Freyja.Freer.bind(fn x ->
Reader.ask()
|> Freyja.Freer.bind(fn y ->
return(x + y)
end)
end)
At the start we have State.get(), which is an "effect constructor" call -
it uses Freer.send_effect to wrap a simple effect data structure in a minimal
Impure structure - try it out yourself in IEx:
Freyja.Effects.State.get()
# %Freyja.Freer.Impure{
# sig: Freyja.Effects.State,
# data: %Freyja.Effects.State.Get{},
# q: [&Freyja.Freer.pure/1]
#}
looking at the expansion again, we can see that the output of State.get() is
immediately fed to a Freer.bind
here's the whole expansion without aliases, for copy/paste into IEx:
freer_1 = (
Freyja.Effects.State.get()
|> Freyja.Freer.bind(fn x ->
Freyja.Effects.Reader.ask()
|> Freyja.Freer.bind(fn y ->
Freyja.Freer.return(x + y) end) end)
)
#%Freyja.Freer.Impure{
# sig: Freyja.Effects.State,
# data: %Freyja.Effects.State.Get{},
# q: [&Freyja.Freer.pure/1, #Function<42.113135111/1 in :erl_eval.expr/6>]
#}
now you can see what the Freer.bind call has done - it's cheating, and hasn't
done any work at all! It's just added the continuation function from its second
argument to the end of the Impure's continuation q - but it has done
nothing to interpret the %State.Get{} effect struct in the data field.
This is the heart of how Algebraic Effects work in Freyja - pure steps in domain
calculations are expressed as a queue of continuations in Freer.Impure structs.
Those pure steps perform no side-effects, and when they want a side-effect
they return an effect operation struct to an Impure, along with a continuation
which resumes the computation once the effect operation has been performed
by the interpreter.
The data effect operation struct in the Impure describes some impure action
that the program wants to do, without specifying anything about how the impure
action is to be achieved - that is left entirely up to the interpreter and its
Handlers. Let's continue playing the interpreter, and
say that our State.Get operation is going to retrieve the value 5 from some
state somewhere - so we pass that value to the first continuation in
the q (which is &Freer.pure/1) which, as expected, just gives us the
value 5 wrapped in a Freer.Pure struct.
freer_2 = (List.first(freer_1.q)).(5)
# %Freyja.Freer.Pure{val: 5}
Since Pure just wraps an ordinary-value, it needs no further
interpretation, and we can pop the first continuation from the q,
and pass the value we have - 5 - straight on to the next
continuation from the q:
freer_3 = (Enum.at(freer_1.q, 1)).(5)
#%Freyja.Freer.Impure{
# sig: Freyja.Effects.Reader,
# data: %Freyja.Effects.Reader.Ask{},
# q: [&Freyja.Freer.pure/1, #Function<42.113135111/1 in :erl_eval.expr/6>]
#}
Now we've got something different! We have a new effect to interpret, an Ask
this time, and more continuations in the q to pass results to... This Ask
struct was built by the Freyja.Effects.Reader.ask() call in the
computation, inside the continuation passed to the first bind call.
Let's skip over the Freer.pure call we now have at the head of the
queue, because we know what it's going to do (just wrap the value in Pure),
and let's say interpreting the Ask returns a value of 15, because we're
both the interpreter and the Handler and we can do that:
freer_4 = (Enum.at(freer_3.q, 1)).(15)
# %Freyja.Freer.Pure{val: 20}
And we have arrived - we called the final continuation, the function taking
parameter y in the expansion corresponding to the last line of
the con block, and it added together our two interpreted values and returned
the result - and since return represents a terminal state and we have no more
continuations on the q, the value is returned to the caller
This process we have followed is essentially what the Freyja interpreter does -
it pattern-matches on the sig and data in Impure structs, finds a Handler
to interpret the effect (producing an ordinary value), then passes that value
to the next continuation in the queue. The Freyja.Run.impl module
orchestrates this loop.
WIP below
3.2 Freer
- the Freer structs
- composing Freer operations
- bind & return
-
the con / defcon sugar macros
- they are simple rewrites - RHS must always be Freer.t() or ISendable
- why they can't have an exactly parallel syntax to with (binds before do/end)
-
def_effect_struct
- ISendable
- handlers - EffectHandler behaviour
3.3 Hefty
- why higher-order effects are different
- the Hefty structs
- composing Hefty operations
- bind & return
-
the hefty /defhefty sugar macros
- RHS must always be Hefty.t(), Freer.t(), ISendable, or IHeftySendable
- interoperability with Freer computations
- catch clauses
-
def_hefty_struct
- IHeftySendable
-
Hefty Algebras - elaboration via interpose
- how higher-order effects maintain their context alongside control effects liike suspend
4. Available effects
4.1 first-order effects
- Reader / TaggedReader
- Writer / TaggedWriter
- State / TaggedSTate
- Throw
- Coroutine
- EffectLogger
4.2 higher-order effects
- Catch
- Bracket
- FxList
- Lift
5. Building your own effects
- signature module
- operation structs
- handler module/s
- algebra modules/s
References
- Hefty Algebras Paper: Poulsen & van der Rest (POPL 2023)
- Heftia (Haskell): sayo-hs/heftia
- Algebraic Effects Overview: What is algebraic about algebraic effects?
- Freer Monads, More Extensible Effects: Kiselyov & Ishii
- freer-simple — a friendly effect system for Haskell: lexi-lambda/freer-simple
- effects - an Elixir effect system: bootstarted/effects
- freer - an Elixir Freer monad: aemaeth-me/freer