An effect notation based on with-clauses
— 2026-03-20

  1. what are effects?
  2. keywords
  3. simple effects
  4. effects with generic params
  5. effect polymorphism
  6. effect algebra
  7. effect operators
  8. assuming totality
  9. conclusion

A bad habit of mine is that I’ll write 10.000 or 20.000 words on a topic, and then never publish that. A lot of my longer-form writing never sees the light of day because it gets stuck in editing purgatory. Editing small pieces is easy and quick, editing long pieces is not. I’ve been wanting to write more about effect notations for a while now and have been noodling on something I hope people might be alright with. At a minimum wouldn't mind seeing pop up in code.

In my last post I mentioned I'd like to see Rust grow from the 2 built-in stable effects it has today to having 9+ built-in effects. This will require a degree of consistency to work with, but requires considering all the interactions between effects. For this to work will this requires introducing an algebra and formal system of reasoning. All while also being familiar to use, and being able to be gradually introduced without breaking backwards compatibility. Which is of course all very easy, simple, and isn't something people will have any opinions on.

What are effects?

Before talking about notation I want to quickly clarify again what effects are. In programming languages we use types to provide information about the inputs and outputs of functions. Effects allow us to provide information about the function itself, including things like “this function can wait without blocking” or “this function can be evaluated during compilation”.

Rust is a systems programming language and because of that it values providing fine-grained, low-level control about structures when needed 1. While an async fn is a function that has “the async effect”, that is different from a function which returns an -> impl Future. Though they both desugar to the same structures, async fn is higher-level and can for example reason about borrows in ways that a plain function returning a future cannot. I think of this as analogous to how in Rust references and pointers are also structurally similar but semantically different.

1

For anyone interested in how effect-forward languages handle effects like async see for example: D. Leijen, “Structured asynchrony with algebraic effects,” in Proceedings of the 2nd ACM SIGPLAN International Workshop on Type-Driven Development, Sep. 2017, pp. 16–29. doi: 10.1145/3122975.3122977.

Effect terminology seems to confuse a lot of people who haven’t read the literature. People usually think effects refer to things like network access and logging, but the literature considers heap access and nondeterminism effects too.

The reason why I care about effects is because it is the simplest abstraction we know of to allow us to reason about concepts like “asynchrony”, “non-termination”, “panic-freedom”, “determinism”, and so on. And not as individual concepts, but as part of a coherent programming language that is actually pleasant to use. Reasoning about effects isn’t simple per se, but it’s simpler than trying to reason about every single one of those features and their interactions independently 2.

2

Yes, I know monads exist. If you want to model arbitrary effects in terms of monads, you have to use the free monad. Effects for Rust are appealing because they solve ordering and composition problems. The free monad is not usually what people think of when they bring up monads as an alternative.

Keywords

Okay vocabulary lesson over, time to dig in. The design in this post is build on three new keywords:

We’ll mention other keywords later on too, but the core of the system revolves around these three.

Simple effects

Let’s start with the basic effect notation for effects which don’t reason about generic effects and effect generics. The intent is to base these around the with-block: a new notation which can be used to define an effectful context. The same notation works for blocks, closures, functions, and methods. This notation draws inspiration from Python's with-statements, though it is of course not quite the same 3.

3

I promise it'll look more Pythonesque if we also consider with for use with effect-handlers. But this post is already too long and I really cannot broach that topic now lol. Just trust me that if it all seems a little distant, effect handlers would bring is closer again.

Instead of relying on long sequences of function prefixes (e.g. pub const placing try gen fn foo() {}) this notation separates effects into its own location, guarded by the with-keyword. This is slightly more verbose when reasoning a single effect, but is significantly more manageable than the prefix form when working with two or more effects.

Finally this notation also provides space for effect aliases: named sets of built-in effects. Just type aliases allow us to abstract repetitive type signatures, and trait aliases allow us to abstract repetitive trait bounds, effect aliases make it possible to abstract repetitive effect signatures.

// alias
eff Alias = async + emplace;

// functions
fn foo() -> i32 { .. }                      // empty set
fn foo() -> i32 with async { .. }           // single
fn foo() -> i32 with async + emplace { .. } // multiple
fn foo() -> i32 with Alias { .. }           // alias

// closures
let foo = || with async { .. };            // single
let foo = || with async + emplace { .. };  // multiple
let foo = || with Alias { .. };            // alias
fn foo(f: impl FnOnce() with async) { .. } // in arg position

// blocks
let x = with async { .. };           // single
let x = with async + emplace { .. }; // multiple
let x = with Alias { .. };           // alias

// trait impls
impl Foo with async for Bar { .. }           // single
impl Foo with async + emplace for Bar { .. } // multiple
impl Foo with Alias for Bar { .. }           // alias

// inherent impls
impl Foo with async { .. }           // single
impl Foo with async + emplace { .. } // multiple
impl Foo with Alias { .. }           // alias

// methods
impl Foo {
    fn foo(&self) with async { .. }           // single
    fn foo(&self) with async + emplace { .. } // multiple
}

This example uses a made-up effect emplace alongside the async effect to show the multi-effect notation. This is because effects for fallibility and iteration are both generic effects (are themselves generic) and the const effect acts more like a set of negative bounds than a set of positive bounds. emplace is one plausible way we could spell placing functions and it works in the same additive way that async does.

Something I particularly like about this is that we gain a degree of consistency between blocks, closures, and functions that we’re missing today. Among other things this removes the somewhat annoying difference between || async {} and async || {} meaning different things.

// Using today's Rust
let x = async { .. };         // async block
let foo = async || { .. };    // async closure
let foo = || async { .. };    // closure returning a future (!)
async fn foo() -> i32 { .. }  // async function

// Using `with`-clauses
let x = with async { .. };        // block
let foo = || with async { .. };   // closure
fn foo() -> i32 with async { .. } // function

I think this also captures some of the elegance of Scala’s = {} body notation, allowing us to disambiguate and label “blocks” as their own item types consistently throughout the language.

Effects with generic params

Some effects like iteration and fallibility carry generic parameters themselves. We can see this reflected in the Iterator and Try traits which carry associated types (Item for Iterator, Output and Residual for Try) which may return different types. But we have future effects we know we’ll need to care about too, like the effectful encoding of iterators that have a next argument. That means that an effect notation for these must be able to specify both input and output types.

In literature effects like throw and gen are usually encoded using a feature called “effect handlers”. As I explained in the terminology section earlier on I usually think of effect handlers as “typed co-routines”. In Rust we already have a notation for regular routines (functions) in Fn() -> (). The obvious way then to spell a “typed co-routine” would be to substitute the Fn for the name of the effect, and use the () -> () for its inputs and outputs. Applied to Rust’s current set of built-in effects (both stable and unstable) it would look as follows:

fn foo() with async { .. }           // asynchrony, no types
fn foo() with gen(i32) { .. }        // iteration, input type only 
fn foo() with gen(i32) -> u32 { .. } // iteration, input and return type (coroutine)
fn foo() with try(Option<!>) { .. }  // fallibility, input type only

Using Flix’ model of effects, individual effect kinds cannot be repeated in a set 4. Applied to this we could not have an instance of gen with a return type and one without within the same set. With that knowledge let’s put all of these together in the same signature to show what a complex composition of these would look like:

4

To be entirely clear though: Flix does not yet support effect which themselves are generic. So what I'm saying here is not entirely accurate and it's more like: "currently Flix does not support repeated effects". From reading their papers it seems like that decision is core to their design though and should remain true even after they add support for generics in effects, hence my statement.

fn get_foo(x: Bar) -> Foo // returns `Foo` by default
with async                // is non-blocking
    + gen(i32) -> u32     // yields `i32`, resumes with `u32`
    + try(Option<!>)      // may fail with `None`
{ .. }

Notably here is that the ordering of the effects in the set does not matter; it will always desugar to the same types. However complex you may think the above statement is, I believe the correct desugaring for it is the following5 (which I hope no unassuming Rust user will ever need to directly interact with in their programs):

5

I know some folks would argue that the right desugaring is not a variation on a LendingIterator returning and yielding impl Future, but should be based on a new trait AsyncGenerator which returns and yields Poll<T> instead. Those designs aren’t meaningfully simpler than what I’m showing here, but are also fundamental broken because they do not support propagating async cancellation. Diving into that more deeply is its own topic tho.

// 1. Returns `Foo` by default
// 2. Does not block between calls to `yield`
// 3. Does not block between the last `yield` and the final `return`
// 4. May fail early by returning `None`
// 5. May hold arbitrary internal references to `Bar`
// 6. Yields `i32`
// 7. Resumes with `u32`
fn get_foo(x: Bar) -> impl LendingReturningIterator<
    Output<'a> = impl Future<Output = impl Try<Output = Foo, Residual = Option<!>>> + 'a where Self: 'a,
    Item<'a> = impl Future<Output = i32>> + 'a where Self: 'a,
    Resume = u32,
> { .. }

In Syntactic musings on the fallibility effect I argued that the try effect might work better if we named it throws, which is the name Java and Swift use. But also that leveraging a pattern-like notation might be nice for fallibility: None instead of Option<!>. Most effectful languages use a noun-verb naming scheme, where the name of the effect is a noun (e.g. Exception) providing access to a verb (e.g. throw). For a closure-based notation it would make the most sense to use only the verb in the notation. So throws would become throw:

fn foo() with await { .. }             // asynchrony, no arguments
fn foo() with yield(i32) { .. }        // iteration, input type only 
fn foo() with yield(i32) -> u32 { .. } // iteration, input and return type (coroutine)
fn foo() with throw(None) { .. }       // fallibility, input type only

This would eliminate the need to also reserve yields and throws in future editions, as well as potentially allowing us to un-reserve the async keyword. At first glance it's perhaps a little funny-looking, but not entirely unreasonable either. Why would we need two keywords for what we could do with one? For anyone keeping score: this would reduce the total number of reserved keywords in Rust by one. We’d be newly reserving eff, with, and throw, but be able to unreserve yeet, async, try, and gen. Not a bad trade if you ask me.

Effect polymorphism

Making functions themselves be generic over effects is a step up in complexity from just reasoning about concrete effects. But while it makes the type system more complex, it would make the overall experience of using Rust simpler by making the language more expressive. There would be no more need for async or fallible 6 forks of the stdlib. As well as allow us to give us the future expressivity needed to work with concepts like “no panic if no panic” and “no divergence if no divergence”.

6

Both the Rust for Linux project and Wasmtime have their own abstractions for dealing with fallibility through the library. Both care about handling OOM errors, which means any allocation might fail, and so any piece of code that might call an API which allocates needs to be able to handle that failure. It’s not quite risen to the level of “fallible fork of the stdlib yet”, but if it existed at least Wasmtime would definitely be interested in that.

The way effect generics work is by introducing a new kind of generic eff which can be used in generic position. This is an effect type variable which would work very similar to type variables in that it represents an unbounded set of effects which can be constrained using where-clauses. More on defining constraints later, here is the basic notation:

// alias
eff Alias = async + emplace;

// functions
fn foo<eff Ef>() -> i32 with Ef { .. }      // generic on self
fn foo<eff Ef>(x: impl Foo with Ef) { .. }  // generic on param

// closures
fn foo<eff Ef>(f: impl Fn() with Ef) { .. }  // in bounds

// data types
struct Foo<eff Ef> { .. }        // item-level definition
impl<eff Ef> Foo with Ef { .. }  // impl-level generic

// traits
impl<eff Ef> Foo with Ef for Bar { .. }    // impl-level generic
impl Foo for Bar { eff Ef = async; }       // associated effect
impl<eff Ef> Foo for Bar { eff Ef = Ef; }  // passing impl-level as associated

// methods
impl<eff Ef> Foo with Ef {              // defining impl-level generic
    fn foo(&self) with Ef { .. }        // using item-level generic
    fn foo<eff A>(&self) with A { .. }  // method-level generic
}

Effect algebra

In the paper “Programming with effect exclusion” 7 the Flix team shows a broad range of conditions people might want to express using effects:

7

M. Lutze, M. Madsen, P. Schuster, and J. I. Brachthäuser, “With or Without You: Programming with Effect Exclusion,” Proc. ACM Program. Lang., vol. 7, no. ICFP, pp. 448–475, Aug. 2023, doi: 10.1145/3607846.

Rust doesn't yet support exclusion and mutual exclusion in bounds, so I’m not going to argue whether we should support this same set of conditions for effects. But what I will argue is that any notation worth considering should reserve notational space to add support for this later if we deem it necessary.

Here is a literal translation of the examples presented in the paper. Emphasis on it being a literal translation: I’m not arguing Rust code should be written this way, this is merely to prove that the syntax I’m outlining in this post can be adapted to work for the full range of conditions presented in the paper.

// Empty set of effects: this function does not
// carry any additional effects beyond the default.
fn noop() { .. }

// Effect union: the returned closure `H` carries
// all effects included in closures `F` and `G`.
fn compose_right<F, G, H, A, B, C, eff Ef1, eff Ef2, eff Ef3>(f: F, g: G) 
-> H
where
    F: FnOnce(A) -> B with Ef1,
    G: FnOnce(B) -> C with Ef2,
    H: FnOnce(A) -> C with Ef3,
    Ef3: Ef1 + Ef2,
{ .. }

// Effect exclusion / upper bound: the closure
// `F` has all effects of `Ef`, but will never
// include the `Block` effect. (example 2.6)
fn on_mouse_pressed<F, eff Ef>(listener: F)
where
    F: FnOnce(MouseEvent) with Ef,
    Ef: !Block
{ .. }

// Disjoint effects: the effects that are part of
// `F` cannot be the same effects that are present
// on `G`.
fn par<A, B, F, G, eff Ef1, eff Ef2>(x: A, f: F, g: G)
where
    F: FnOnce(A) -> B with Ef1,
    G: FnOnce(A) -> B with Ef2,
    Ef2: !Ef1,
{ .. }

// Disjoint effects with a shared component: the
// effects that are part of `F` cannot be the
// same effects that are present on `G`, except
// for the `Network` effect which is present on both.
fn par<A, B, F, G, eff Ef1, eff Ef2>(f: F, g: G)
where
    F: FnOnce(A) -> B with Ef1,
    G: FnOnce(A) -> B with Ef2,
    Ef1: Network,
    Ef2: !Ef1 + Network,
{ .. }

// Mutual exclusion: at most one of `F` and `G`
// can have the `Throw` effect. (example 4.2)
fn hof<T, F, G, eff Ef1, eff Ef2>(f: F, g: G)
where
    F: FnOnce(T) -> T with Ef2,
    G: FnOnce(T) -> T with Ef1,
    Ef1: !Ef2 & Throw,
    Ef2: !Ef1 & Throw,
{ .. }

This to me feels like a notation which fully integrates into Rust; extending the language, without fundamentally changing the feel of it. Though it does change the meaning of where: in addition to constraining type variables it can now also constrain effect variables. To me that feels like the right tool for the purpose, but it did take me a second to come around to it.

Effect operators

So far we’ve only looked at item signatures, but not at function bodies. So let’s do that now! When working with effects in function bodies there are three categories of effect operations to consider:

CreatePropagateConsume
Asynchrony-.awaitblock_on/pub async fn 8
Fallibilitythrow/yeet?match
Iterationyieldfor..in..yield/.reyield 9for..in
8

On host platforms with native async support the “consume” operation may exist across the ABI boundary. When for example targeting WASI 0.3 from Rust you would export an async function directly. You can think of this as the runtime being a part of the operating system rather than a part of the program, so you don’t terminate in a local block_on call.

9

Something like Python’s yield from would be great to have for iterators too. But in typical Rust fashion we would probably want it as a postfix operator.

Both create and consume operations must be specific to the effect, so we can't generalize over those. For example async is itself not generic over any types, and the source of any awaits typically tracks back to system calls or some other futures-level implementations. But propagation of effects can absolutely be generic, and I propose we use the already reserved .do keyword for that:

fn foo<F, eff Ef>(f: F) -> i32
where
    F: FnOnce() -> i32 with Ef,
with Ef {
    f().do; // inserts `.await`, `?`, `for..in..yield` as needed
}

This function operates on an unconstrained set of effects, and so the function body needs to carry a visual indicator that the function may not progress after that point. ? may return an error, .await may not be resumed (async cancellation), for..in..yield may not be looped again (iterator cancellation), etc. While we do not know which other effects we might potentially want to add in the future (e.g. effect handlers could add any), it wouldn’t change the way users reason about .do: “A function may suspend or even exit on .do. That’s no less intuitive than .await.

One concern I’ve heard raised in the past is that we could not possibly abstract over effects because effect handling code can be found everywhere. And they raise a reasonable point: half of what makes async code so appealing to write is the ability to introduce ad-hoc concurrent execution. It indeed seems like a shame to dispense with that entirely.

I’ve long-since held that Rust would benefit from being able to reason about concurrency at a high level. I believe that Swift has largely the right idea here using their async let feature. In Automatic interleaving of high-level concurrent operations I showed how we might be able to adapt such a feature to Rust using .co_await, but without the need for any allocations10 and built entirely from join operations.

10

The Waker Allocation problem is real though. But we can choose not to allocate and get slightly worse concurrency, which is still better than no concurrency.

Performing a complete and truthful translation of the Swift example from the post would probably be worth writing its own post about. But to make the point of plausibility: if .do would be the keyword to abstract away .await, we could imagine a keyword .co to abstract away.co_await:

fn make_dinner<eff Ef>() -> SomeResult<Meal> with Ef {
    let veggies = chop_vegetables().co;
    let tofu = marinate_tofu().co;
    let oven = preheat_oven(350).co;
    
    let dish = Dish(&[veggies, tofu]).co;
    oven.cook(dish, Duration::from_mins(3 * 60)).do
}

I think of .co as spiritually pushing in the same direction as the go keyword in Go. But structured in nature, executing lazily, abstracting over all effects, and using stackless concurrency. Though I'm not suggesting we should pursue this system now though; merely that it seems possible to do if we ever decide that we want it enough.

Assuming totality

The total effect marks the absence of all other effects. When a function is “total” its complete set of effects is empty. This is an even stronger guarantee than being “pure”, which still allows functions to loop indefinitely and panic. According to the lead author of Koka a typical Koka program is about 70% total, 15% pure, and only 15% needs to be able to access host APIs (syscalls).

In Rust we cannot express totality today. Depending on whether you target core, alloc, std, or write a const fn the functions in your program are assumed to be able to handle different effects. I don't think we realized when designing this just how neat this layering actually maps to effects:

The default assumption is that a function gets access to whatever capabilities the target defines, and you can opt-out of those to change capabilities via e.g. const fn. We haven’t really mentioned const in any of the examples in this post so far because it more or less has the opposite polarity of most other effects mentioned. It would be nice if we could define the capabilities of const as an opt-in instead of opt-out.

So say we wanted to change Rust to assume totality by default? How could we not only make that possible, but also practical? The first step would be to create aliases which the stdlib can provide to make it easy to opt back into the existing behavior.

pub eff Pure = panic + diverge;
pub eff Core = Pure + static;
pub eff Alloc = Core + heap;
pub eff Std = Alloc + Host;

Alloc and Host should probably be effect handlers and not primitive effects (or sets of more fine-grained effect handlers), which we aren’t discussing in this post. So just assume that we’re able to define those somehow. Because functions are now always assumed to be total (have no effects), using the aliases we can then begin to precisely annotate each function:

fn foo() {}             // Has no effects (total)
fn foo() with Pure {}   // Equivalent to writing `const fn` today
fn foo() with Core {}   // Equivalent to targeting `core` today
fn foo() with Alloc {}  // Equivalent to targeting `alloc` today
fn foo() with Std {}    // Equivalent to targeting `std` today

Assuming Koka's numbers translate to Rust: around 70% of new code would be total and therefore easy to write. But for the remaining 30% which isn't total, writing with Pure and with Std everywhere things would get old, fast. Even more so for existing projects. Niko Matsakis has brought up an idea in the past of introducing “scoped generics” to reduce some amount of repetition in clauses. I don’t think he’s ever written about it, so I’ll try and share my best interpretation of what I think he meant and apply it to effects.

Imagine that instead of writing with-clauses for every item in a scope, we could introduce module-level ascriptions that apply to every item contained within. To mark all items in a specific module as having access to a set of effects we could write something like the following:

pub mod bar with Std {  // Adds `with Std` to all functions in the module
    pub fn foo() {}     // `with Std` is implied
    pub fn bar() {}     // `with Std` is implied
}

Instead of duplicating with-clauses everywhere we would now just have one per module, though this notation would only work well for inline modules. Files in Rust are also considered modules, and so we would probably want a notation to allow files to be self-describing by adding a notation to say:: "All items in this current file (module) should carry these effects. "I believe that something like mod self could probably get us there:

mod self with Std;  // Adds `with Std` to all functions in the file (module)

pub fn foo() {}     // `with Std` is implied
pub fn bar() {}     // `with Std` is implied

Usually projects are made up of many files, and adding the same pragma to the top of each file can still be very repetitive. To opt-out of std and into core today, you can write #![no_std] at the top of your crate. And to then opt-into support for alloc you can then follow that up with extern crate alloc;.

What if instead of #![no_std]/extern crate alloc, we instead allowed you to write crate self; similar to mod self. Assuming no effects by default, re-enabling the capabilities of core, alloc, and std could then all be defined using variations of the same line defined once at the start of each crate:

crate self with Alloc;

pub mod bar {
    pub fn foo() {}   // `with Alloc` is implied
}

We should probably place restrictions on where and how crate self could be used to ensure optimal compiler performance. But this kind of notation would be the kind of thing we need if we ever wanted to change defaults from implied Std to instead be total over an edition. Adding a single line per crate to opt back into existing behavior for core, alloc, and std seems like a reasonable cost to support a transition like that.

Conclusion

If at this point your head is spinning a little and you feel like surely this amount of complexity and features is unwarranted: that’s an absolutely normal reaction to have. I know this post is a lot to take in, and I’ve intentionally omitted most of the rationale for each decision I’ve made. As I mentioned: I’ve tried writing that post; it was double the length of this post and I couldn't figure out how to turn it into something I could actually publish.

I want to again emphasize that what I’m outlining here is not intended to be taken as-is, let’s get cracking on this straight away and drop everything else. No, this post is really just meant to show that a plausible path exists that gets us to a point where reasoning about effects is no harder than reasoning about type variables.

I suspect not everyone will like the with keyword for effects. I too would prefer something shorter or perhaps even a sigil if it was possible. But writing do async for functions sounds silly to me, and re-using the commonly used \ sigil does not look good in block position. I invite people to attempt to translate the examples themselves to see what I mean. To me the with keyword seems to strike the best balance between legibility and brevity. There of course there are a lot of other things I did not cover in this post, including:

But that’s fine I think. There’s always more to discuss in future posts. With this post I hope I’ve at least been able to sketch out one reasonable possible notation for effects in Rust. Or as some people have told me: “This is the first notation I’ve seen I did not actively dislike”.

Thanks to Oli Scherer for reviewing parts of the other post I was writing prior to publishing. That review was really helpful and actually made me pause and rethink what I should focus on describing. Though I'm thanking Oli by name here, none of the quoted opinions in this post are theirs.