An effect notation based on with-clauses
— 2026-03-20
- what are effects?
- keywords
- simple effects
- effects with generic params
- effect polymorphism
- effect algebra
- effect operators
- assuming totality
- 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.
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.
- Effect types: Named effects that we can reason about, used to grant permissions to perform specific actions within scopes.
asyncandtryare examples of effect types. - Built-in effects: Effects which are defined by the language, and not by any users. This post focuses primarily on built-in effects, but intentionally keeps space open for the possibility of non-built-in effects (user-defined effects) later down the line.
- Primitive effects: Effects which encode programming language semantics directly, and aren’t backed by any other mechanism. For example:
const fnremoves access to the heap, statics, non-determinism, and host APIs. All of these require compiler-level reasoning. - (Algebraic) Effect Handlers: Best thought of as typed co-routines. Instead of having a single keyword
yieldwhich can suspend and resume with a value, algebraic effect handlers make it possible to create your own typedyieldthat must be explicitly handled. In literature iteration, asynchrony, and fallibility are usually encoded using effect handlers. “User-defined effects” usually refer to introducing the effect handlers feature. This post is not about this feature. - Effect polymorphism: Functions and other items which take effects as generic parameters (effect type variables). This is similar to type generics and const generics.
- Generic effects: effects which carry generic parameters themselves. For example: iterating functions must be able to specify which type they yield.
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.
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:
eff: When used in parameter position this denotes an “effect generic”. When used as a standalone item this denotes an “effect item” (e.g. “effect alias”, “associated effect”, etc.)with: is used for effect-generic clauses, similar to (but not the same as) how thewherekeyword is used for type-generic clauses..do: is used to apply any effects on functions which are fully generic over effects.
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.
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:
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):
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”.
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:
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.
- Empty set of effects: declare no effects are present .
- Union: merge two or more sets of effects. When used with concrete effects it also sets a lower bound for which effects are included.
- Exclusion: exclude specific effects from the set. When used with concrete effects this sets an upper bound for which effects can be included.
- Mutual exclusion: declare two effects as mutually exclusive.
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:
| Create | Propagate | Consume | |
|---|---|---|---|
| Asynchrony | - | .await | block_on/pub async fn 8 |
| Fallibility | throw/yeet | ? | match |
| Iteration | yield | for..in..yield/.reyield 9 | for..in |
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.
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.
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:
- total: no effects
- const: panic + diverge
- core: panic + diverge + globals
- alloc: panic + diverge + globals + heap
- std: panic + diverge + globals + heap + syscalls
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:
- How to implement effect handlers
- How to implement capabilities
- The difference between capabilities and effect handlers
- A comparison with other proposed notations
- What this notation would mean for const traits and the try blocks
- Practical examples of working with effect generics in types and traits
- How to actually begin working towards this, if we wanted to
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.