- Installation
- What is Freyja?
- 0. tl;dr
- 1. What are Algebraic Effects?
- 2. A Quick Tour
- 3. How does it work
- 4. Available effects
- 5. Building your own effects
- 6. Performance
- References
- License
Add freyja to your list of dependencies in mix.exs:
def deps do [ {:freyja, "~> 0.1.2"} ] endFreyja 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).
Algebraic Effects and Handlers for Elixir. Both first-order and higher-order effects are supported, and a library of common effects is included. 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}) endAlgebraic 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.
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() endThe 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.
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() endYour 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 endAt 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.
Not nearly an exhaustive list, but there are IEx runnable examples for each case!
This was the use-case that lead to Freyja - having built a library in Clojure which supported serializable and resumable computations - albeit with very strict limitations on the form of the computation to a simple list of steps - I wanted to explore the possibility of a more general approach, with a more natural style, and which wouldn't limit the shape of the computation.
The result is Freyja, and the EffectLogger effect. It's an advanced effect, and if you add its Handler to the start of any handler queue, it will log all effects which are emitted by a computation or any of the other Handlers.
Having captured a structured log of all the effects emitted by a computation, the EffectLogger can then be used to:
- resume a suspended computation from a hot or cold (serialized then deserialized) log
- rerun a failed computation from a hot or cold log
When resuming or rerunning, if there is a completed StepLogEntry for a step in the computation, the EffectLogger will intercept the step and supply the logged value for the step to the next step, without doing any effectful work - so re-running computations, and cold-resumes follow thhrough the pure elements of the computation, rebuilding the continuations, until they reach the end of the logged computation, at which point the normal logging behaviour comes into play and the computation resumes where it left off before being serialized. (hot resumes, where continuations are still available, don't follow this path, and immediately resume from where they left off)
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 }, }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}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.
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
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.
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}) endThe 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
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
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) endThis 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
The con and hefty macros provide a with-like syntax that rewrites to nested bind calls. This is similar to Haskell's do notation.
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
# 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)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) endThe 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) end- 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)
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.
- Reader / TaggedReader
- Writer / TaggedWriter
- State / TaggedSTate
- Throw
- Coroutine
- EffectLogger
- Catch
- Bracket
- FxList
- Lift
- signature module
- operation structs
- handler module/s
- algebra modules/s
Freyja's continuation-based architecture is designed for clarity and correctness. Benchmarking shows that for typical usage patterns, performance is excellent.
Key findings:
- The
conandheftymacros generate nested binds, keeping continuation queues short (1-2 items typically) - With realistic effect workloads, queue overhead is negligible - actual effect interpretation dominates
- Pathological cases (10,000+ explicitly chained binds with trivial work) can exhibit O(n²) behavior, but such patterns are rare in practice and lose access to intermediate values
Recommendation: Don't worry about performance for typical usage. If you're building very deep computation chains, consider restructuring as batched operations.
For detailed benchmarks and analysis, see PERFORMANCE.md.
- 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