Skip to content

mccraigmccraig/freyja

Repository files navigation

Test Hex.pm Documentation

Freyja

Table of Contents

Installation

Add freyja to your list of dependencies in mix.exs:

def deps do [ {:freyja, "~> 0.1.2"} ] end

What 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, 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}) end

1. 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)}") end

This 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 Serializable Coroutines

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)

(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 below

Example 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.2 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) end

This 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) end

In 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.result

In 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.3 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.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 ) end

Use 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}) end

The 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.ask calls 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:

  • get the current State and bind it to variable x
  • ask the Reader for its value, and bind it to variable y
  • return x+y to 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 current non-terminal value of the computation to an interpreter, to ask the State effect for its value
  • the interpreter identifies a Handler which can interpret State requests and calls it to get a value from somewhere - it could be anywhere at the discretion of the Handler - and the computation is resumed with that value which is bound immediately to x (x is in fact a function parameter - see section 3.1 for how this happens)
  • Reader.ask() builds a simple %Ask{} struct which is returned as a non-terminal value to the interpreter, requesting the value from the Reader effect
  • the interpreter identifies a handler which can interpret Reader requests, calls it to get a value, and the computation is resumed again with that value, which is bound to y
  • return returns the terminal value x+y to 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:

  1. Effect binding (x <- effect()) becomes a bind call
  2. Pure binding (x = value) stays as a regular assignment
  3. The last expression is returned as-is (it must be a Freer.t() for con, or a Hefty.t() for hefty - so plain values should be wrapped with return(value))
  4. Functions with con or hefty bodies can be defined with defcon defhefty

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) end

Key Points

  • The right-hand side of <- must return an effect computation (Freer.t() for con, Hefty.t() for hefty)
  • First-order effects (State, Reader, etc.) are auto-lifted in hefty blocks via the IHeftySendable protocol
  • The return(value) function wraps a value in a pure computation (Freer.pure or Hefty.pure)
  • Unlike with, bindings happen inside the do block, not before it - this is due to limitations in the syntax expressible with Elixir macros (vs special forms like with)

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 the data - an "effect" module
  • data - a piece of data which can be interpreted (by a Handler) 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{} end

There are a few functions you will need to know about:

  • Freer.return(any) :: Freer.t() - a.k.a Freer.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 interpreter
  • Freer.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/1 in its continuation queue. Remember Freer.Impure is 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() - bind is how computations move forward from one step to the next. It takes a Freer computation, extracts a value from it (the job of the interpreter) and gives that value to a "continuation" - which returns another Freer - 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) end

and 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


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

6. Performance

Freyja's continuation-based architecture is designed for clarity and correctness. Benchmarking shows that for typical usage patterns, performance is excellent.

Key findings:

  • The con and hefty macros 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.


References


License

MIT License

About

Algebraic effects for Elixir

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages