PonderingEffects

Introduction

Well I have some ramblings about algebraic effects I wanna record, but I don’t want to put them into a full blog post. I seem to have failed at that, so here we are, enjoy your gigantic schlob of text. This was triggered by this blog post and the replies on lobste.rs, which got me reading about effects in general and Effekt in particular.

That said I don’t understand Effekt that well, so if I keep reading I might end up with more insights. Don’t take this as the end of the road, just a map of the parts I’ve wandered through so far.

Last updated March 2026.

WTF is an effect

Effects happen when we want our programming language to have some metadata about functions (and data), and we want it to be sticky and inferrable. The easiest concrete example of this metadata is stuff like “can this function panic” or “does this function do I/O”, which are often useful questions to ask when you’re rummaging around in a big codebase. Usually these are questions you have to find the answer to the hard way, but like type inference, they are questions a compiler can answer for us if we give it some rules of how they work. Rust has a great example of effects in its unsafe annotation. If you have a function A that calls another function B, and B is unsafe, then A has to be unsafe as well. So unsafeness is “sticky”, it bubbles up the call chain – until in Rust at least, you use an unsafe block to suppress this. Rust’s const for compile-time evaluation is similar but is a bit of its own special case, and async is sort of an effect, so Rust people are apparently thinking of generalizing them..

So, if function B does I/O or allocates memory, and function A calls B, then A also does those things. So that gets us the name “effect”, we are talking about the side-effects that these functions have. Tracking and accounting for these side effects is sometimes useful for a compiler as well as for a human. Rust has two effects currently, unsafe and const, but from talking with people it’s fairly common to wish for other effects like “does not panic” or “does not allocate memory”. So while unsafe and const are entirely separate features and kinda don’t really generalize, there’s talk of making a proper effect system that encompasses those and other things. This transitive “effects in a function implies effects in its caller” relationship can apply to lots of useful things.

Rust has a number of effect-ish things as well. Deriving traits on data follows a similar deductive style: if a struct is Copy, then any other struct it contains must also be Copy. Same with other marker traits like Eq or Sync. async functions are also in there somewhere, idk. Generics also have similar bubble-y effects; it’s fairly common to add a generic type or a trait bound to a function or type somewhere and then go “oh wait this has to come from somewhere” and have the <T> and where T: SomeStuff annotations propagate up through its callers or containing types.

ezpz, right?

Okay, so that seems easy enough to do then, just attach effects to functions, attach them to function signatures or infer them, and bubble them up through the static call chain. But it’s not that easy! Effects become sticky like generic types do. Consider a generic function fn map<From, To>(l: List<From>, f: fn(From) -> To) -> List<To>. Is this function unsafe? Well, probably not, there’s no reason you couldn’t write map() with safe code. But what if f() is unsafe then? map() will call f(), so it’s calling an unsafe function, so map() has to be unsafe. This leads us to effect polymorphism, to make it possible to say “map(l, f) has some set of effects, and also whatever effects f() has”. This makes effects start looking very much like generic types or trait bounds, and so researchers have come up with ways of making them obey the same math and writing effect checkers that can validate and infer effects the same way that type checkers can validate and infer types. 1

I realize now that we all know a particular manifestation of this: the async “function coloring” problem.

(There’s other annoying design sub-problems around this as well. For example, a nuisance that pokes me semi-often in Rust is wanting to derive Default on some struct that contains Vec<T>, but T does not implement Default. But Vec<T> shouldn’t need to implement Default, because it defaults to an empty list and never needs to call T::default(). But the derive machinery doesn’t know that and forces you to implement Default by hand. Not a dealbreaker, but certainly something I wish could be better. I’m sure we’ll find other similar special cases that require exceptions to simple effect inference and polymorphism as we go.)

As usual with anything that looks like generic types, this is very nice and straightforward in theory and then explodes into a many-petalled flower of complexity the moment you try to do anything real with them. Yoshua’s blog post has a good overview of the zoo of variations people have come up with. In my own journeys through the idea here we’ll reconstruct pretty much all of them apart from algebraic effect handlers. Those are a bit of an odd duck though, so let’s talk about them first. They seem like a cool way to generalize exception handlers, and thus use effects to encompass checked exceptions. You can attach a callback to an effect, and then when something executes that effect, it finds the nearest effect callback in scope and jumps to that. The handler can then Do Stuff, and decide whether to go back to where the effect happened or somewhere else. You could then use these handlers for things like allocating memory or doing I/O, and swap out which ones you’re using in various contexts.

I’m gonna be real for a second here though, I don’t really know why you would want algebraic effect handlers. Or rather, I don’t know why you’d want them attached to effects, as people seem to keep trying to do. It’s a cool idea, but in my opinion effects are so much more interesting and useful on their own that trying to attach coroutines to them really just muddies the waters. They’re just coroutines, and there’s pleeeeeenty of ways to make coroutines work. Lua coroutines. Common Lisp conditions. Erlang processes, aka coroutines with a built-in scheduler and compiler-inserted yield points. Coroutines are also a pretty big feature in terms of the complexity they add to a language runtime: if you want coroutines you’re best off building the language around them, not attaching them to your conventional C/Java/OCaml-ish notion of “functions, data structures, a call stack, and maybe a GC” model after the fact. Considering that, as mentioned, effect polymorphism is already a complexity explosion, this seems like way more work than it needs to be.

So we’re not gonna talk about effect handlers anymore. Of course, in the legendary words of Tomi Sawyer, if we knew what we were doing it wouldn’t be called research, now would it?2 So I’m not discounting the possibility that “effects that can trigger coroutines” is exactly what we want somewhere, I just don’t think it’s what I want here.

ezpz? wrong.

Anyway, back on topic. And the topic is, effects are complicated.

The main inspiration for all this was this post on lobste.rs which caused some neuron activations by showing that instead of attaching effects to functions as if they were signatures or trait bounds, we can just pass an effect to a function like any other sort of argument. This resulted in me reading a bunch about Effekt, which is pretty darn cool but also quite mind-bending once you get past the basics. For example, from the docs on effect inference:

While calling a method on an object would require us to know the receiver (e.g. exc.throw("boom!")), every operation can also be used as an effect by omitting the receiver and prepending the keyword do:

def div(a: Double, b: Double) =
  if (b == 0.0) { do throw("division by zero") }
  else { a / b }

Using throw as an effect operation means that we do not know (and maybe do not care) who the receiver (and thus implementor) is. We simply express that we want to throw and leave it to the context to decide what to do.

Context, hm? Context seems to come up a lot in various features I want… 🤔

There’s lots of little ergonomic things to unpack from this also, though. Representing effects as functions/interfaces with a slightly different call syntax also sounds very appealing, both as a signal to the programmer that “Something Happens here”, and to the compiler that “ok you need to try to infer an effect of some kind, but only here”. Defining effects as collections of functions instead of just a tag is wild and I don’t yet know what to about it. And Effekt goes further with it and creates a whole slightly-skewed model of functions and closures in general which I’m still trying to absorb.

effects == data?

So, back to effects as data, though. What’s the difference between:

fn foo() -> i32 with Alloc

and

fn foo(eff: Alloc) -> i32

Well… in the second one the effect is a concrete object you have to pass to the function. And… uh, that’s it, maybe? If you can’t conjure an Alloc object out of nowhere, and you have another function that calls foo(), it needs to also have a signature like fn bar(eff: Alloc). We’ve taken a hidden global side effect and made it explicitly attached to a piece of data. These tend to be called capabilities, and the world would be a better place if OS API’s used them more, but the general idea is you take some kind of global ambient state and turn it into an explicit object that represents or contains that state. These objects generally can be copied, destroyed, passed around etc, but usually capabilities can’t be created out of nothing. File handles are an example of a capability3: if you don’t have one, you can’t read or write to a file, and if you do have one, you know you can read or write to a file and it won’t vanish out from under you. (In principle, anyway. OS API’s tend to be stupid.)

So we’ve reinvented Zig’s tendency for passing allocators to every function that needs one. Great. Of course having to manually thread these capabilities through functions just to do omni-present things like allocating memory or doing I/O is annoying a lot of the time, so people come up with ways to make the computer do it for them. This can take several forms. You can add inference to effect polymorphism, so you can write the map() example above without having to list every possible effect that f() might ever have. Alternatively you can add implicit arguments of some kind or another, sort of like the Effekt example above, which makes the compiler figure it out for you. Those tend to look a lot like type inference applied to values in a function’s scope, so instead of having to write bar(my_alloc) every time, you just write bar() and the compiler goes “okay is there an object of type Alloc in scope? Yes, and there’s only one, so the only possible solution for bar(_) is to turn it into bar(my_alloc).”

This sort of approach can get applied to lots of different things and tends to get called “implicits”, and the only languages I know that really do it are Scala (and maybe OCaml, soon?). They are a bit spooky ’cause they’re magical and fragile, but they seem to be tractable if you’re careful around them. This whole post is about the tension between “make implicit things explicit” and “make explicit things easier”.

Anyway, this now also sounds a lot like the “context objects” in Jai and Odin. Those are basically a separate stack of objects that lives alongside/within the function call stack at runtime. You don’t have to call bar(my_alloc), you just call bar() and inside bar() it says “give me the Alloc object closest to the top of the stack” and the compiler generates the code to do that lookup for you. Figuring out which object that is just happens at runtime when the program scans the stack of context objects, instead of at comptime when the compiler tries to find whether an Alloc object exists in scope and which one to use.

There’s a name for this pattern as well: dynamic scope. It’s basically the ability to do this:

fn foo() { println("{}", x) }   // x is not declared
foo();                          // runtime error
let x = 1;
foo();                          // prints 1
{
  let x = 2;
  foo();                        // prints 2
}
foo();                          // prints 1

Once omnipresent in Lisp, Scheme kinda killed dynamic scope in the 1980’s by proving that lexical scope could be implemented efficiently, and it generally has not been mourned very much.4 It sorta lives on as global vars in scripting languages like Lua and Python, but still tends to be discouraged there; as time goes on software engineering has frowned progressively harder at any kind of “spooky action at a distance”, for very good reasons. But the newest generation of system languages seems to be reinventing dynamic scope, for the reasons things are always invented: they’re useful. (Or at least, they seem like a good idea at the time.) Making your dynamic scope a stack separate from the normal function call stack is a pretty cool idea, you see shades of it in other places like structured logging systems that let you push and pop context, nested exception/panic handlers, stuff like that. (Garnet doesn’t do anything like this yet, but it will probably have it as a library somewhere.) So having some tools for making dynamic scope easier to handle is probably worth thinking about.

wtf do we actually want from life?

Okay we’ve gotten a little distracted from effects. The point is there’s this big, multi-faceted design space being explored right now, which is full of related and interlocking and overlapping parts, and in the end it’s all about answering two questions:

  • How the heck do we make the language let us reason about and control side-effects?
  • How the heck do we make the language not bother us with this information when we don’t care about it?

This second point is key. Life would be far easier if humans were fine having every single function look like this:

fn frobnosticate_the_encabulator<X,Y,Z>(
  alloc: Alloc<X>,
  filesystem: Filesystem<X,Y>,
  x: X,
  some_y: fn(Y) -> impl Encabulate<TheZ=Z>,
  another_y: Y,
  magical_context: Ctx<X,Y>,
  logger: Log<Z>,
  other_logger: Log<X>,
  we_probably_need_this_somewhere: Log<Y>,
)
where X: Copy + Clone + Sync, 
where Y: Copy + Clone + Eq + PartialEq, 
with Safe, Impure, NoPanic, Yield, NoSpawnThread, Terminates, FixedStackSize,
with Y: Safe, Impure, NoPanic, Yield, NoSpawnThread, Terminates, FixedStackeSize {
  ...
}

The computer doesn’t give a damn about how terrible that looks. It’s happy to have all the info! The problem is that writing it, keeping it in sync as programs change, and using it is really terrible on our poor monkey hardware. Our brains were designed to chase down gazelles and run away from lions, and really haven’t had much time to adapt to whatever the hell we use them for these days. That big hairy function signature is full of information that is entirely unnecessary 98% of the time, and utterly vital 2% of the time. That’s why we have lots of languages that don’t bother caring about any of it and just cover it up at runtime (Java, C#, Typescript, OCaml, pick your poison), and a few languages that do let you care about it when you want to (Rust, C, C++). And the world absolutely cannot get rid of that second category, despite trying for a long time.5

All right, let’s finally actually talk about effects. Really now that I’ve gone through the background, I only have a few conclusions:

  • Most effects can be represented by just turning them into function calls, turning those function calls into data, passing those data around by some form of non-global lookup. Capabilities, context objects, dynamic dispatch, whatever floats your boat. (Effekt lets you define them as trait-like interfaces, which is pretty cool, but iiuc you could do the same thing in Java with a vtable and it would look mostly the same to the programmer.) These are the real side-effects, and are about turning global state into local state. I/O, logging, allocating memory, panicking, OS system calls, all of these are about touching global state. Using effects to say “what global state does this function touch” is totally valid, but there’s other approaches as well. Even unsafe comes under this heading, you can rephrase unsafe at its most basic level as the function fn write_ptr<T>(*mut T, T).
  • Some effects are not actually about side-effects, but are rather about expressing details of how the program and implementation works. This is stuff you can’t represent just with “can you turn this into a function passed as an argument to another function?”: can this function be evaluated at compile time? Does this function terminate, or inversely, does this function have a chance of not terminating? Does this function recurse? Does this function do some kind of non-stack-based control flow like async calls or exceptions? (Trying to reason about that one is probably where coroutines get involved.) These are less about “touching global state” and more about proving stuff about your program. I guess the other kind of effects are also about proving stuff, they just can all be proven with the one particular tactic of “follow the static function call chain”?
  • I keep feeling like effects also need to apply to data, and nobody seems to heckin’ think about it much as far as I can tell? Rust does this a lot with marker traits: Copy is the most common one, but there are others such as Pin. Send and Sync are interesting ones ’cause the compiler doesn’t need to know anything special about them, they’re just tags defined by the stdlib. Clone and Ord and such can be expressed by normal functions and some syntax sugar, but Copy and Pin affect how the language machinery works. The reasoning around them is so similar to function effects: “if X contains something with property Y, then X has property Y” and/or “X has property Y iff everything it contains has property Y”. It works whether X is a structure or a function, these can’t be unrelated things, right?

Here comes the algebra

Beyond all this stuff there’s a broad amount of “how the heck should you design and use these” that we don’t reeeeally have real information for. Do we want our “function-call-y effects” to be actual functions/objects, or not? Are you supposed to make your effect be UsesLogging or DoesNotUseLogging? With the “effect = function call” kind, this is pretty obvious, but with the others… not so much. Should Copy be Copy or NonCopy? Should Pin be Move instead? In general, should an effect restrict or broaden what a function or piece of data can do? Well, usually both have up and down sides, and which you prefer is a bit contextual.

But when you generalize it, this is how we get “effect algebra”. If you can phrase “if X contains something with property Y, then X has property Y” as… well, if you define x := {A, B, C} then you can define x_has_y as just x_has_y := has_y(A) or has_y(B) or has_y(C). Or if you invert the sense of your effects, ie you want x to have y if all of its components have y rather than if any of its components do, then it’s x_has_y := has_y(A) and has_y(B) and has_y(C).

But you don’t even need to restrict this to boolean algebra. If you want to figure out the stack size a function uses, say to statically verify your program can’t stack overflow, you do it like this (if I can conjure up some kind of cursed pseudo-Prolog syntax for it):

x() :- f1(), f2(), f3().
stack_size(x) :- size_of_locals(x) + max(stack_size(f1), stack_size(f2), stack_size(f3)).

Similarly with data:

struct x {
  x1: i32,
  x2: i64,
  x3: SomethingElse,
}

size_of(x) :- size_of(x1) + size_of(x2) + size_of(x3) + maybe_some_padding_magic(x1, x2, x3).

So the “metadata you might want to know about something” type of effect doesn’t just have to be a true/false flag, it can be an integer that you then compose with plus, minus, max, whatever operation you want. And presumably it can also be just about anything else you want if you try hard enough, leading to Effekt’s “effects are interfaces full of functions” approach. Fortunately I haven’t yet thought of many actually good use cases for this sort of thing yet, but reasoning about stack and object size is probably a good one, and if there’s one then there’s probably more.

Conclusions

Ummmm, what else. Here’s things I don’t have a better place for:

  • Lots of this stuff is lossy. Your compiler can’t always prove your function doesn’t loop forever. But occasionally it can, or can prove that your function does loop forever, and occasionally that’s useful.
  • Lossiness is fine, we write plenty of very robust and good programs using types like i32 instead of integer_between(-4342, 17,385,993). Do we want to be able to express the latter kind? Mmmmmmm, not that often. F#’s units-of-measurement are great in principle, but I do physical science stuff for fun and I still haven’t really bothered using them. Features have costs, they need to pay their way.
  • Sometimes you know more than the compiler. (Hopefully fairly often, tbh.) So you need to be able to tell the language what’s going on when this happens. This tends to be a fraught process, a la Rust’s unsafe, so ideally you design the language to avoid it as much as practical while still allowing the escape hatch.
  • But lots and lots of utility can come out of the compiler automating the checking for the properties you want. I was so damn mad when I discovered that C++’s noexcept was a promise made by you to the compiler, not a request for the compiler to check your work like a type is, and it has scarred me for life.

Soooooo, conclusions. I don’t have a coherent design to propose that encompasses all of this stuff yet, just a bunch of interesting connections:

You can view effects as two main categories: side-effects that can be expressed in your language, and side-effects that are about how your language is implemented. The first can generally just be represented as function calls and capabilities, the second could be written that way if you try hard enough but probably isn’t worth it. They really needs compiler magic to implement, because they’re about how the compiler works. Is the split between these two categories the same as “dynamic behavior” vs “static behavior” of the program?

There’s a fundamental tension between “tell the computer what to do” and “get the computer to figure out what is going on”. This is all about a human ergonomics problem, not really a computation program. Some things you care about, some things you don’t, and which is which depends on your problem domain. Do I care if f() writes to a log? If I’m writing a web app backend, probably not, but if I’m writing a drone flight controller, probably yes. So you not only have to express and handle and compose effects, you need a way of telling the computer when you care about them and when you don’t.

There’s lots of ways of telling the computer which parts you care about. Implicits and context objects/dynamic scope are the ones that people are currently thinking about/using, but there’s probably others, including Zig’s “just pass the allocator object by hand, you lazy shlub”.

What if you registered effect handlers like Effekt does, but they were just normal functions instead of coroutines? You couldn’t use them for non-local control flow like exception handlers, but they might still be pretty useful for lots of other stuff. Someone should look into that.

You can totally apply effects to data structures, same way you can apply them to functions. But nobody seems to have thought about that much besides Rust stdlib developers? Surely I can’t be the first person to make that connection.

Have fun!


  1. This can get even more wild if you let it ’cause that means effects could be a dynamic property, such as if random() { map(list, safe_f) } else { map(list, unsafe_f) }. But most people seem to care about static effects, as a way to prove behaviors at compile-time.↩︎

  2. This quote also gets attributed to Albert Einstein, but so many things get misattributed to him that I don’t trust it. Maybe someday I’ll –hah– do the research and find out for real.↩︎

  3. Even though they do tend to get conjured out of nothing; if POSIX were more capability-based you’d have to open files with open(filesystem_handle, path, flags") and when a program is started the OS would hand it a filesystem_handle that only allows it to access files the user has permissions for.↩︎

  4. Emacs lisp is the only Lisp-family language I know of that still uses it.↩︎

  5. If you were interested in programming languages in the 2000’s you might remember as I do that everyone who hated C and C++’s problems kept waiting for something to replace them: Java, C#, Javascript, OCaml, Haskell, D, anything. None of those replaced C/C++ because while you can make those languages do the things that C/C++ can do, it’s so much work and requires such deep knowledge of the language implementation that it’s usually easier to just write the code in C. Rust is succeeding where those languages failed by, instead of saying “we can fix all these easy problems, then we cover up hard problems with some runtime code”, it actually manages to fix a lot of the hard problems around C’s design space.↩︎