Algebraic Effects in Practice with Flix

Algebraic effects are not just a research concept anymore. You can use them in real software, today. Here’s why you’d want to do that, in order of importance:

  1. Effects make your code testable

    One of the central goals of enterprise software development. Dependency injection, mocking, architecture patterns like clean, hexagonal, DDD are all meant to tackle this. Effects solve this elegantly by separating the “what” from the “how”.

  2. Effects give immediate visibility into what your own and 3rd-party code is doing

    Supply chain attacks are real. And they will get worse with more AI slop entering our codebases. Tools like Go’s Capslock fix this by following the whole chain of calls to stdlib functions. Effects provide this by design, as all effects are tracked by the type and effect system.

  3. Effects enable user-defined control flow abstractions

    Solving the “what color is your function” problem1. You can also leverage effects to implement Async/await, coroutines, backtracking search and other control flow patterns as user libraries without hard-coding these features into the language.

Algebraic effects come from the pure functional world, serving a purpose similar to monads — keeping track of and having control over side effects. Like monads, they enable us to write our core logic with pure functions and push side effects like IO outwards, closer to application boundaries.

Unlike monads, effects are easy to grasp for a regular developer and give immediate benefits when starting out. For me personally they’re a more natural abstraction for managing side effects — after all, effects are in the name.

Starting out as an academic concept, algebraic effects were introduced to the world by research languages like Eff, Koka, Effekt, Frank, Links, and more recently Ante.

People have also applied effects in practice, so far usually via a monad-based approach, by making libraries in established languages like Scala Kyo / Cats Effect / ZIO; Typescript Effect and Effector, C# language-ext, C libhandler and libmprompt, C++ cpp-effects, various Haskell libraries, etc.

In addition to forcing you into a monadic way of thinking, libraries implementing effects are limited by their host languages.

In this article, I will walk you through applying algebraic effects on a real world example using Flix, a new programming language that is built with effects from the ground up, and supports functional, logic and imperative paradigms.


Table of Contents

  1. Type and Effect System: A Motivating Example
  2. Effect Handlers: Building Intuition
  3. Real-World App: AI movie recommendations
  4. Where to Go From Here
  5. Extra: Why Algebraic Effects are Algebraic and how they relate to monads
  6. Footnotes

Currently only few languages support effects out of the box. The only one that I know of besides Flix is Unison. OCaml has a language extension, but there is no support yet in the type system. Haskell has added support for delimited continuations, but effects are still only available via libraries.

In addition to having a “type and effect system” that improves function signatures and makes sure all effects are handled, Flix supports traits, local mutability via regions, working with immutable or mutable data, and Go/Rust-like structured concurrency. It also has a first-class Datalog integration. But I will only focus on effects here. Let’s start.

Type and Effect System: A Motivating Example đź”—

Imagine a function called calculateSalary:

def calculateSalary(base_salary, bonus_percent):

Based on the function name and the signature, one can assume it’s just a pure function that does some calculations. In a statically typed language you are also guaranteed that the function arguments and outputs will be of a certain type.

But even if the types are correct, nothing stops our little calculateSalary() from, say, sending an offensive email to your grandma2:

def calculateSalary(base_salary, bonus_percent):
    server.sendmail("grandma@family.com", "Your cookies are terrible!")
    return base_salary * (1 + bonus_percent/100)

If, on the other hand, you extend your type system with effects, you will see immediately in the signature that this function may do something fishy:

def calculateSalary(salary: Float64, percent: Float64): 
    Float64 \ {Email} = {
//            ^^^^^^^ Notice the Email effect!

Of course, in real life the issue it’s not usually about the grandma. Instead, this function could throw an exception — still quite dangerous. If you forget to handle the exception, your app will crash. Or another very realistic scenario is that calculateSalary() calls a database to get some employee details for calculations, and you forgot to provide a database connection string. That can also result in an exception or a panic.

Effect Handlers: Building Intuition đź”—

The job of the type and effect system is not just to improve our function signatures. It’s also making sure all the effects are handled somewhere. This is where effect handlers come in.

Usually when people talk about algebraic effects what they’re actually talking about is effect handlers. If you know exceptions, effect handlers are super easy to understand. Here’s a Jenga analogy:

Imagine the call stack is a Jenga tower. New blocks are carefully added each time you call a function.

jenga
Saurav S, Unsplash

When an exception is thrown, your whole nice Jenga tower gets destroyed, all the way up to the catch() block. The catch block can safely handle the error, but the stack is unwinded, meaning you lose all of the state you had in your program before throwing the exception. You have to build your tower again, from scratch.

When using effect handlers you can actually go back to your original computation after the handler is done handling the effect. The handler can also return some values back to your program, and it can even resume multiple times with different return values. You also still have the option of not resuming at all and aborting the program — that would be the effect equivalent of exceptions.

Back to the Jenga analogy: if your tower is about to fall down, with effects you can freeze it mid-collapse. You then call someone for help (handler), and they decide whether to let the tower fall, magically restore it to the previous statlte. Or even hand you different blocks to try the same move (call the continuation) again, possibly multiple times with different inputs. Your Jenga tower ends up looking more like a fork or a tree, with multiple different copies of your blocks branching out at some point from the base.

To make this more concrete, let’s start by reproducing exceptions with effects. Here’s how a try/catch looks like in Python:

def divide(x, y):
    try:
        return x / y
    except ZeroDivisionError:
        print("Division by zero!")
        return None

Here’s the equivalent code in Flix. We first define an Exception effect and a divide() function:

eff Exception {
    def throw(msg: String): Void
}

def divide(x: Int32, y: Int32): Int32 \ Exception = 
    if (y == 0) {
        Exception.throw("Division by zero!")
    } else {
        x / y
    }

And then provide a handler for this effect somewhere, preferably close to main():

def main(): Unit \ IO = 
    run {
        println(divide(10, 0))
    } with handler Exception {
        def throw(msg, _resume) = println("Error: ${msg}")
    }

What this does is registers an effect called Exception with a method throw(). We then perform this effect in our function when there’s an error, similar to throwing an exception in the Python version. Control is transferred to the effect handler, which then decides how to handle the exception, similar to a catch() block in Python.

Notice we never call resume() from the handler. This results in the program being aborted, just like with exceptions. Graphically, this can be represented as follows:

block-beta
    columns 2
    
    A["Statement 1"] space:1
    B["Statement 2"] space:1
    C["Statement 3"] space:1
    D["Perform Effect"] space:1
    space:1 E["Handle Effect"]
    space:1 F["Process & Exit"]
    space:1 space:1
    
    D --> E
    
    style D fill:#ffcccc,color:#000
    style E fill:#ccffcc,color:#000
    style F fill:#ccffcc,color:#000

So far so good, but this is not much different from Python. To really take full advantage of effect handlers, we can use resume() to return to the original computation and proceed from the line after the effect was performed:

eff ResumableException {
    def askForInput(): Int32
}

def divide(x: Int32, y: Int32): Int32 \ ResumableException = 
    if (y == 0) {
        let newY = ResumableException.askForInput();
        x / newY
    } else {
        x / y
    }

def main(): Unit \ IO = 
    run {
        println(divide(10, 0))
    } with handler ResumableException {
        def askForInput(_, resume) = {
            println("Enter a new divisor:");
            resume(5) // Or get from user input
        }
    }
block-beta
    columns 2
    
    A["Statement 1"] space:1
    B["Statement 2"] space:1
    C["Statement 3"] space:1
    D["Perform Effect"] space:1
    space:1 E["Handle Effect"]
    space:1 F["Resume"]
    space:1 space:1
    G["Statement 4"] space:1
    H["Statement 5"] space:1
    I["Complete"] space:1
    
    D --> E
    F --> G
    
    style D fill:#ffcccc,color:#000
    style E fill:#ccffcc,color:#000
    style F fill:#ffffcc,color:#000

I called the effect ResumableException here, but it’s not really an exception anymore, because the program continues normally.

At this point we can use this power bestowed on us by effects and handlers to roll our own Async/await:

eff Async {
    def await(url: String): String
}

def fetchData(): String \ Async = 
    Async.await("https://api.example.com/data")

def processData(): String \ Async = {
    let data = fetchData();
    "processed: ${data}"
}

def main(): Unit \ IO = 
    run {
        let result = processData();
        println(result)
    } with handler Async {
        def await(url, resume) = {
            // Simulate async HTTP request
            let result = "data from ${url}";
            resume(result)
        }
    }

See how easy that was? This approach also avoids function coloring, since we didn’t need to use special keywords anywhere. Here’s a graphic version:

block-beta
    columns 2
    
    A["Statement 1"] space:1
    B["Statement 2"] space:1
    C["await operation"] space:1
    space:1 H1["Start async work"]
    space:1 H2["⏳ Long pause..."]
    space:1 H3["⏳ Still waiting..."]
    space:1 H4["âś… Async complete"]
    space:1 F["Resume with result"]
    space:1 space:1
    D["Statement 3"] space:1
    E["Complete"] space:1
    
    C --> H1
    F --> D
    
    style C fill:#ffcccc,color:#000
    style H1 fill:#ccffcc,color:#000
    style H2 fill:#fff3cd,color:#000
    style H3 fill:#fff3cd,color:#000
    style H4 fill:#d1ecf1,color:#000
    style F fill:#ffffcc,color:#000
    style D fill:#e7f3ff,color:#000
    style E fill:#d4edda,color:#000

That’s cool, but we can do more. Effect handlers allow you to resume multiple times:

eff Choose {
    def choose(): Int32
}

def explore(): String \ Choose = {
    let x = Choose.choose();
    let y = Choose.choose();
    "${x}, ${y}"
}

def main(): Unit \ IO = 
    run {
        println(explore())
    } with handler Choose {
        def choose(_, resume) = {
            resume(1);
            resume(2);
            resume(3)
        }
block-beta
    columns 4
    
    A["Statement 1"] space:1 space:1 space:1
    B["Statement 2"] space:1 space:1 space:1
    C["Statement 3"] space:1 space:1 space:1
    D["Perform Effect"] space:1 space:1 space:1
    space:1 space:1 E["Handle Effect"] space:1
    space:1 F1["Resume 1"] F2["Resume 2"] F3["Resume 3"]
    space:1 G1["Statement 4a"] G2["Statement 4b"] G3["Statement 4c"]
    space:1 H1["Statement 5a"] H2["Statement 5b"] H3["Statement 5c"]
    space:1 R1["Resume to Main"] R2["Resume to Main"] R3["Resume to Main"]
    J["Statement 6"] space:1 space:1 space:1
    K["Complete"] space:1 space:1 space:1
    
    D --> E
    F1 --> G1
    F2 --> G2
    F3 --> G3
    H1 --> R1
    H2 --> R2
    H3 --> R3
    R1 --> J
    R2 --> J
    R3 --> J
    
    style D fill:#ffcccc,color:#000
    style E fill:#ccffcc,color:#000
    style F1 fill:#ffffcc,color:#000
    style F2 fill:#ffffcc,color:#000
    style F3 fill:#ffffcc,color:#000
    style G1 fill:#e6f3ff,color:#000
    style G2 fill:#ffe6f3,color:#000
    style G3 fill:#f3ffe6,color:#000
    style H1 fill:#e6f3ff,color:#000
    style H2 fill:#ffe6f3,color:#000
    style H3 fill:#f3ffe6,color:#000
    style R1 fill:#d4edda,color:#000
    style R2 fill:#d4edda,color:#000
    style R3 fill:#d4edda,color:#000
    style J fill:#cce5ff,color:#000
    style K fill:#b3d9ff,color:#000

With this, you can implement things like coroutines:

block-beta
    columns 3
    
    A1["Coroutine 1: Start"] space:1 A2["Coroutine 2: Start"]
    B1["Statement 1"] space:1 B2["Statement 1"]
    C1["yield to Co2"] H1["Scheduler"] space:1
    space:1 space:1 C2["Statement 2"]
    space:1 space:1 D2["yield to Co1"]
    space:1 H2["Scheduler"] space:1
    D1["Statement 2"] space:1 space:1
    E1["yield to Co2"] H3["Scheduler"] space:1
    space:1 space:1 E2["Statement 3"]
    space:1 space:1 F2["Complete"]
    F1["Complete"] space:1 space:1
    
    C1 --> H1
    H1 --> C2
    D2 --> H2
    H2 --> D1
    E1 --> H3
    H3 --> E2
    
    style C1 fill:#ffcccc,color:#000
    style D2 fill:#ffcccc,color:#000
    style E1 fill:#ffcccc,color:#000
    style H1 fill:#ccffcc,color:#000
    style H2 fill:#ccffcc,color:#000
    style H3 fill:#ccffcc,color:#000
    style A1 fill:#e6f3ff,color:#000
    style B1 fill:#e6f3ff,color:#000
    style D1 fill:#e6f3ff,color:#000
    style F1 fill:#e6f3ff,color:#000
    style A2 fill:#ffe6f3,color:#000
    style B2 fill:#ffe6f3,color:#000
    style C2 fill:#ffe6f3,color:#000
    style E2 fill:#ffe6f3,color:#000
    style F2 fill:#ffe6f3,color:#000

Generators:

block-beta
    columns 2
    
    A["Start generator"] space:1
    B["Statement 1"] space:1
    C["yield value 1"] H1["Return value"]
    space:1 H2["⏸️ Paused"]
    D["next() called"] H3["Resume generator"]
    E["Statement 2"] space:1
    F["yield value 2"] H4["Return value"]
    space:1 H5["⏸️ Paused"]
    G["next() called"] H6["Resume generator"]
    H["Statement 3"] space:1
    I["return (done)"] H7["Signal complete"]
    
    C --> H1
    H3 --> D
    F --> H4
    H6 --> G
    I --> H7
    
    style C fill:#ffcccc,color:#000
    style F fill:#ffcccc,color:#000
    style I fill:#ffcccc,color:#000
    style H1 fill:#ccffcc,color:#000
    style H3 fill:#ffffcc,color:#000
    style H4 fill:#ccffcc,color:#000
    style H6 fill:#ffffcc,color:#000
    style H7 fill:#ccffcc,color:#000
    style H2 fill:#fff3cd,color:#000
    style H5 fill:#fff3cd,color:#000
    style D fill:#e7f3ff,color:#000
    style G fill:#e7f3ff,color:#000

And backtracking search:

block-beta
    columns 4
    
    A["Start search"] space:1 space:1 space:1
    B["choose option"] space:1 space:1 space:1
    space:1 H1["Try option 1"] space:1 space:1
    space:1 space:1 C1["Explore path 1"] space:1
    space:1 space:1 D1["❌ Dead end"] space:1
    space:1 H2["Backtrack"] space:1 space:1
    space:1 H3["Try option 2"] space:1 space:1
    space:1 space:1 space:1 C2["Explore path 2"]
    space:1 space:1 space:1 D2["âś… Success!"]
    E["Resume with solution"] space:1 space:1 space:1
    F["Complete"] space:1 space:1 space:1
    
    B --> H1
    H1 --> C1
    D1 --> H2
    H2 --> H3
    H3 --> C2
    D2 --> E
    
    style B fill:#ffcccc,color:#000
    style H1 fill:#ccffcc,color:#000
    style H2 fill:#f8d7da,color:#000
    style H3 fill:#ccffcc,color:#000
    style C1 fill:#fff3cd,color:#000
    style D1 fill:#f8d7da,color:#000
    style C2 fill:#d1ecf1,color:#000
    style D2 fill:#d4edda,color:#000
    style E fill:#ffffcc,color:#000
    style F fill:#d4edda,color:#000

Hopefully this gives you a taste of how effect handlers work. This is just a sketch though — you can read more on this and see examples in the Flix docs.

Question

What's your primary programming language?

These questions help direct new content. Want to get notified when something new is posted?

Defining our own control flow abstractions is great, but most of the time regular async/await and/or coroutines are enough for the job.

What is extremely useful for daily programming is that effects let you separate the declaration of the effect (the operation, or the effect “constructor”) from it’s implementation, defined by the effect handler.

Add some effect definitions:

eff Database {
    def getUser(id: Int32): Option[User],
    def saveUser(user: User): Unit
}

Then use these definitions to perform effects in your code:

def updateUserEmail(userId: Int32, newEmail: String): Result[String, User] \ {Database} = {
    match Database.getUser(userId) {
        case Some(user) => {
            let updatedUser = {user | email = newEmail};
            Database.saveUser(updatedUser);
            Ok(updatedUser)
        }
        case None => {
            Err("User not found")
        }
    }
}

This replaces the need for dependency injection, since you can provide different handlers for these database operations in production vs testing:

def main(): Unit \\ IO = { // production handler, uses a real database
    run {
        updateUserEmail(123, "new@example.com")
    } with handler Database {
        def getUser(id, resume) = {
		        // real db query
            resume(user)
        }
        def saveUser(user, resume) = {
		        // real db query
            resume()
        }
    }
}

def testUpdateUserEmail(): Unit = { // test handler, just stubs
    let testUser = {id = 123, email = "old@example.com"};
    run {
        let result = updateUserEmail(123, "new@example.com");
        assert(result == Ok({testUser | email = "new@example.com"}))
    } with handler Database {
        def getUser(id, resume) = resume(Some(testUser))
        def saveUser(user, resume) = {
            assert(user.email == "new@example.com");
            resume()
        }
    
}

In my opinion, the biggest advantage that effect handlers give is that they abstract away the patterns associated with DDD, Clean Architecture, Hexagonal architecture, etc. commonly found in enterprise code.

All these architectures give you some sort of way to isolate your core logic, which should be pure, from infrastructure and app logic, with deals with external dependencies. But you have to commit to an architecture and the whole team has to be disciplined enough to stick to for this to work.

Using effects encourages separating the definition of effect operations from implementation by default, meaning you don’t really need these architecture patterns anymore.

This is great, since relying on team discipline exclusively rarely works. It also saves a bunch of time otherwise spent on bike shedding.

Effect handlers also allow you to easily install stubs, which you can use to create quick test cases without boilerplate, just by swapping handlers:

def testErrorConditions(): Unit = {
    run {
        let result = updateUserEmail(123, "new@example.com");
        assert(result == Err("User not found"))
    } with handler Database {
        def getUser(_, resume) = resume(None) // Stub: always return None
        def saveUser(_, resume) = resume()             // Won't be called
    }
}

def testSlowDatabase(): Unit = {
    run {
        let result = updateUserEmail(123, "new@example.com");
        assert(result.isOk())
    } with handler Database {
        def getUser(id, resume) = {
            Thread.sleep(100);  // Simulate slow query
            resume(Some({id = id, email = "old@example.com"}))
        }
        def saveUser(user, resume) = {
            Thread.sleep(50);   // Simulate slow save
            resume()
        }
    }
}

You can even make a handler that records all interactions instead of executing them. There are many possibilities here.

Real-World App: AI movie recommendations đź”—

To bring this all together, let’s make a real application using effects.

Our app will fetch some movie data from TheMovieDB, and then use an LLM to recommend some movies based on user preferences provided from the console.

Flix interoperates with the JVM, meaning we can call code from Java, Kotlin, Scala, etc.

First, let’s define the two custom effects we will need: MovieAPI and LLM:

eff MovieAPI {
    def getPopularMovies(): String
}

eff LLM {
    def recommend(movies: String, preferences: String): String
}

We can then perform the effects in main like so, providing some basic handlers that use the Flix’s stdlib HTTP client:

def getRecommendation(preferences: String): String \ {MovieAPI, LLM} = {
    let movies = MovieAPI.getPopularMovies();
    LLM.recommend(movies, preferences)
}

def main(): Unit \ {Net, IO} = 
    run {
        let suggestion = getRecommendation("action movies");
        println(suggestion)
    } with handler MovieAPI {
        def getPopularMovies(_, resume) = {
            let response = HttpWithResult.get("https://api.themoviedb.org/3/movie/popular", Map.empty());
            match response {
                case Result.Ok(resp) => resume(Http.Response.body(resp))
                case Result.Err(_) => resume("[]")
            }
        }
    } with handler LLM {
        def recommend(movies, prefs, resume) = {
            let prompt = "Movies: ${movies}. User likes: ${prefs}. Recommend one movie.";
            let response = HttpWithResult.post("https://api.openai.com/v1/completions", Map.empty(), prompt);
            match response {
                case Result.Ok(resp) => resume(Http.Response.body(resp))
                case Result.Err(_) => resume("Try watching a classic!")
            }
        }
    } with HttpWithResult.runWithIO

Notice that both effects are quite generic. So we can easily swap either the movie API or the LLM provider without touching anything in the core logic:

// Switch to different movie provider
with handler MovieAPI {
    def getPopularMovies(_, resume) = {
        let response = HttpWithResult.get("https://api.imdb.com/popular", Map.empty());
        // ... handle IMDB response format
    }
}

// Switch to different LLM provider  
with handler LLM {
    def recommend(movies, prefs, resume) = {
        let response = HttpWithResult.post("https://api.anthropic.com/v1/messages", Map.empty(), prompt);
        // ... handle Claude response format
    }
}

To get the user input we will need to include the standard Console effect:

def main(): Unit \ {Net, IO} = 
    run {
        Console.println("What movie genres do you enjoy?");
        let preferences = Console.readln();
        let suggestion = getRecommendation(preferences);
        Console.println("Recommendation: ${suggestion}")
    } with handler MovieAPI { /* ... */ }
      with handler LLM { /* ... */ }
      with Console.runWithIO
      with HttpWithResult.runWithIO

We can also add some basic logs using the standard Logger effect:

def getRecommendation(preferences: String): String \ {MovieAPI, LLM, Logger} = {
    Logger.info("Fetching popular movies...");
    let movies = MovieAPI.getPopularMovies();
    Logger.info("Getting LLM recommendation...");
    LLM.recommend(movies, preferences)
}

def main(): Unit \ {Net, IO} = 
    run {
        /* ... console interaction ... */
    } with handler MovieAPI { /* ... */ }
      with handler LLM { /* ... */ }
      with Console.runWithIO
      with Logger.runWithIO
      with HttpWithResult.runWithIO

That’s it! Let’s run the app and test it manually like so:

 flix run Main.flix
What movie genres do you enjoy?
> sci-fi horror
[INFO] Fetching popular movies...
[INFO] Getting LLM recommendation...
Recommendation: Based on your interest in sci-fi horror, I recommend "Alien" - a perfect blend of both genres!

We can also easily write tests for the core logic by providing test handlers for our movie and LLM effects:

def testRecommendation(): String = 
    run {
        getRecommendation("comedy")
    } with handler MovieAPI {
        def getPopularMovies(_, resume) = {
            resume("""[{"title": "The Grand Budapest Hotel", "genre": "comedy"}]""")
        }
    } with handler LLM {
        def recommend(movies, prefs, resume) = {
            resume("I recommend The Grand Budapest Hotel - perfect for comedy lovers!")
        }
    } with handler Logger {
        def log(_, _, resume) = resume()  // Silent in tests
    }

def runTests(): Unit \ IO = {
    let result = testRecommendation();
    println("Test result: ${result}")
}

Where to Go From Here đź”—

Read the Flix docs

Especially on cool features like effect polymorphism, effect exclusion etc. Check out code examples in the repo

Join the community and contribute with libraries

The Flix compiler and stdlib are quite feature-rich at this point, and having JVM interop means you have all the essentials you need to write practical code. But there are still very few pure Flix libraries. So it’s very valuable to contribute some. The ideas I can think of are, for example, rebuilding standard things like Web frameworks in an effect oriented way,. Or taking advantage of the unique feature set in Flix to build something entirely new.

Explore effect-oriented programming

While I personally like Flix and can recommend it to others, there are other ways you can use effects for real-world software. If you’re in Typescript or Scala, try out Effect or ZIO/Kyo/Cats. If you’re looking for other languages that support effects natively, and you’re not afraid of Haskell-like syntax, check out Unison. They have a bunch of other concepts I find cool, like a better distributed computing model and the code being content-addressed.

Thanks for reading! I hope this article was useful. Hit me up if you have questions or feedback, and check out my website, where I’m exploring sustainable tech and coding practices: relax.software

Question

What should I write about next?

These questions help direct new content. Want to get notified when something new is posted?

Extra: Why Algebraic Effects are Algebraic and how they relate to monads đź”—

Okay, practical people have left the room. Following sections are nerds-only.

For some reason, all the content I’ve been reading on algebraic effects uses this term a lot, but no one explains why specifically they’re called “algebraic”. So I did some digging.

Turns out, algebraic effects are “algebraic” because they can be described with laws and equations, like in algebra — the kind we learn at school. Which is I guess why they’re easier to grasp than monads — unlike algebra, you usually don’t study category theory in high school.

But the algebraic part only applies to the effect “constructors”, i.e the operations themselves like get() or put() for the state effect.

Effect handlers, on the other hand, are not algebraic at all, which can be a bit confusing. But it makes sense if you think about it — the purpose of handlers is to act as “deconstructors”, interpreting our algebraic effect operations by means of things that cannot be described by algebraic equations alone, such as continuations .

In fact, effect handlers are often (but not always) implemented via delimited continuations. There are also other, static/lexically scoped and maybe more performant approaches being explored, such as this one

“Real” algebraic effects don’t require monads. Monads and algebraic effects are two different concepts tackling similar problems. One is expressible in terms of the other, but algebraic effects are arguably more flexible.

You could actually implement algebraic effects using a continuation monad. If we don’t care about types, effects are perfectly expressible with monads and vice versa

The problems appear when we introduce types into the picture. In a properly typed world, you can’t actually reproduce the same expressiveness you get with effects using monads. You’ll end up breaking the type system or reducing expressiveness at some point.

Effects are, in this sense, more “powerful” than monads with their natural type system: you can express infinitely many computations with them. E.g if you use a tick() effect and you do a bunch of sequential tick() s, the result will be a distinct computation each time. With monads and their natural type system the set of computations you could express is finite.

Additionally, with monads you commit to a specific interpretation of an effect in advance, while effects completely decouple effect definition from it’s implementation.

Finally, effects are easier to compose than monads. With monad transformers you quickly hit the wall having to define a bunch of different combinations that each have distinct semantics. Effects compose naturally.

So while effect libraries in languages like Typescript and Scala are able to express effects using monads3, and the behavior could be identical at runtime, this cannot replace having an actual type and effect system, with effects being properly typed.

Question

How do you usually learn about new things?


Footnotes đź”—

Footnotes

  1. “What color is your function” is a problem explored in this article. In languages which have Async baked in via special keywords (e.g JavaScript async/await) it becomes a pain to refactor and to combine synchronous and asynchronous code. If you make one function deep in the call stack async, all the callers will have to be made Async as well, or await() the results. With effects you don’t have this issue as there are no keywords and no special behavior. Async is simply done with effect handlers. ↩

  2. I like the grandma example more than the “launch missiles” popular in the Haskell world. Took it from this article by Kevin Mahoney. It’s somehow more offensive ↩

  3. See some examples in this article. This also shows how Haskell’s new delimited continuation support can be used to implement algebraic effects and handlers ↩

Get Notified About New Posts

Occasional posts on maintainable software and effect-oriented programming.