My first attempts to find out who they are and why they should excite me were unsuccessful. I found several PDFs , but they confused me even more. (For some reason, I fall asleep while reading academic articles.)
But my colleague Sebastian continued to call them the mental model of some of the things we do in React. (Sebastian works in the React team and put forward a lot of ideas, including Hooks and Suspense.) At some point, it became a local meme in the React team, and many of our conversations ended with the following:
It turned out that algebraic effects are a cool concept, and it is not as scary as it seemed to me at first after reading these PDFs. If you just use React, you don’t need to know anything about them, but if you, like me, are interested, read on.
(Disclaimer: I'm not a researcher in the field of programming languages and may have messed up something in my explanation. So let me know if I'm wrong!)
Algebraic effects are currently an experimental concept from the field of study of programming languages. This means that unlike if
, for
or even async/await
expressions, you most likely will not be able to use them right now in production. They are supported by only a few languages that were created specifically to study this idea. There is progress in their implementation in OCaml, which ... is still ongoing . In other words, watch, but do not touch with your hands.
Imagine that you are writing code using goto
, and someone is telling you about the existence of if
and for
constructs. Or maybe you are mired in a callback hell and someone is showing you async/await
. Pretty cool, isn't it?
If you are the type of person who likes to learn programming innovations a few years before it becomes fashionable, it's probably time to get interested in algebraic effects. Although not necessary. This is how to talk about async/await
in 1999.
The name may be a little confusing, but the idea is simple. If you are familiar with try/catch
blocks, you will quickly understand algebraic effects.
Let's recall try/catch
. Say you have a function that throws exceptions. Perhaps there are several nested calls between it and the catch
:
function getName(user) { let name = user.name; if (name === null) { throw new Error(' '); } return name; } function makeFriends(user1, user2) { user1.friendNames.add(getName(user2)); user2.friendNames.add(getName(user1)); } const arya = { name: null }; const gendry = { name: '' }; try { makeFriends(arya, gendry); } catch (err) { console.log(", : ", err); }
We throw an exception inside getName
, but it pops up through makeFriends
to the nearest catch
. This is the main property of try/catch
. Intermediate code is not required to care about error handling.
Unlike error codes in languages like C, when using try/catch
you do not need to manually pass errors through each intermediate level to handle the error at the top level. Exceptions pop up automatically.
What does this have to do with algebraic effects?
In the above example, as soon as we see an error, we will not be able to continue executing the program. When we find ourselves in a catch
, normal program execution will stop.
Everything is over. It's too late. The best we can do is recover from a failure and perhaps somehow repeat what we did, but we cannot magically “go back” to where we were and do something else. And with algebraic effects, we can.
This is an example written in a hypothetical JavaScript dialect (let's call it ES2025 for fun), which allows us to continue working after the missing user.name
:
function getName(user) { let name = user.name; if (name === null) { name = perform 'ask_name'; } return name; } function makeFriends(user1, user2) { user1.friendNames.add(getName(user2)); user2.friendNames.add(getName(user1)); } const arya = { name: null }; const gendry = { name: '' }; try { makeFriends(arya, gendry); } handle(effect) { if (effect === 'ask_name') { resume with ' '; } }
(I apologize to all readers from 2025 who search the Internet for “ES2025” and fall into this article. If by then algebraic effects would become part of JavaScript, I would be happy to update the article!)
Instead of throw
we use the hypothetical keyword perform
. Similarly, instead of try/catch
we use the hypothetical try/handle
. The exact syntax doesn't matter here - I just came up with something to illustrate the idea.
So what is going on here? Let's take a closer look.
Instead of throwing an error, we carry out the effect . Just as we can throw any object, here we can pass some value for processing . In this example, I pass a string, but it can be an object or any other data type:
function getName(user) { let name = user.name; if (name === null) { name = perform 'ask_name'; } return name; }
When we throw an exception, the engine looks for the closest try/catch
handler in the call stack. Similarly, when we execute an effect , the engine will look for the closest try/handle
effect handler on top of the stack:
try { makeFriends(arya, gendry); } handle(effect) { if (effect === 'ask_name') { resume with ' '; } }
This effect allows us to decide how to handle the situation when the name is not specified. New here (compared to exceptions) is the hypothetical resume with
:
try { makeFriends(arya, gendry); } handle(effect) { if (effect === 'ask_name') { resume with ' '; } }
This is something you cannot do with try/catch
. It allows us to go back to where we performed the effect and pass something back from the handler . : -O
function getName(user) { let name = user.name; if (name === null) { // 1. name = perform 'ask_name'; // 4. ... (name ' ') } return name; } // ... try { makeFriends(arya, gendry); } handle(effect) { // 2. ( try/catch) (effect === 'ask_name') { // 3. , ( try/catch!) resume with ' '; } }
It takes a little time to get comfortable, but conceptually this is not much different from try/catch
with a return.
Note, however, that algebraic effects are a much more powerful tool than just try/catch
. Error recovery is just one of many possible use cases. I started with this example only because it was easiest for me to understand.
Algebraic effects have interesting implications for asynchronous code.
In languages with async/await
functions usually have a color . For example, in JavaScript, we cannot just make getName
asynchronous without infecting makeFriends
and its calling functions with async. This can be a real pain if part of the code sometimes needs to be synchronous and sometimes asynchronous.
// ... async getName(user) { // ... } // ... async function makeFriends(user1, user2) { user1.friendNames.add(await getName(user2)); user2.friendNames.add(await getName(user1)); } // ... async getName(user) { // ... }
JavaScript generators work in a similar way: if you work with generators, then all the intermediate code should also know about generators.
Well, what does it have to do with it?
For a moment, let's forget about async / await and go back to our example:
function getName(user) { let name = user.name; if (name === null) { name = perform 'ask_name'; } return name; } function makeFriends(user1, user2) { user1.friendNames.add(getName(user2)); user2.friendNames.add(getName(user1)); } const arya = { name: null }; const gendry = { name: '' }; try { makeFriends(arya, gendry); } handle(effect) { if (effect === 'ask_name') { resume with ' '; } }
What if our effects handler cannot return the "spare name" synchronously? What if we want to get it from the database?
It turns out that we can call resume with
asynchronously from our effect handler without making any changes to getName
or makeFriends
:
function getName(user) { let name = user.name; if (name === null) { name = perform 'ask_name'; } return name; } function makeFriends(user1, user2) { user1.friendNames.add(getName(user2)); user2.friendNames.add(getName(user1)); } const arya = { name: null }; const gendry = { name: '' }; try { makeFriends(arya, gendry); } handle(effect) { if (effect === 'ask_name') { setTimeout(() => { resume with ' '; }, 1000); } }
In this example, we call resume with
only a second later. You can consider resume with
callback, which you can call only once. (You can also show off to friends by calling this thing “a one-time limited continuation ” (the term delimited continuation has not yet received a stable translation into Russian - approx. Transl.).)
Now the mechanics of algebraic effects should be a little clearer. When we throw an error, the JavaScript engine spins the stack by destroying local variables in the process. However, when we execute the effect, our hypothetical engine creates a callback (actually a “continuation frame”, approx. Transl.) With the rest of our function, and resume with
will call it.
Again, a reminder: the specific syntax and specific keywords are entirely invented just for this article. The point is not in it, but in mechanics.
It is worth noting that algebraic effects arose as a result of the study of functional programming. Some of the problems they solve are unique only to functional programming. For example, in languages that don't allow arbitrary side effects (like Haskell), you should use concepts like monads to drag effects through your program. If you have ever read the monad tutorial, then you know that it can be difficult to understand. Algebraic effects help to do something similar with a little less effort.
That is why most discussions about algebraic effects are completely incomprehensible to me. (I don't know Haskell and his “friends.”) However, I think that even in an unclean language like JavaScript, algebraic effects can be a very powerful tool for separating the “what” from the “how” in your code.
They allow you to write code that describes what you are doing:
function enumerateFiles(dir) { const contents = perform OpenDirectory(dir); perform Log('Enumerating files in ', dir); for (let file of contents.files) { perform HandleFile(file); } perform Log('Enumerating subdirectories in ', dir); for (let directory of contents.dir) { // enumerateFiles(directory); } perform Log('Done'); }
And later wrap it with something that describes the “how” you do it:
let files = []; try { enumerateFiles('C:\\'); } handle(effect) { if (effect instanceof Log) { myLoggingLibrary.log(effect.message); resume; } else if (effect instanceof OpenDirectory) { myFileSystemImpl.openDir(effect.dirName, (contents) => { resume with contents; }); } else if (effect instanceof HandleFile) { files.push(effect.fileName); resume; } } // `files`
Which means that these parts can become a library:
import { withMyLoggingLibrary } from 'my-log'; import { withMyFileSystem } from 'my-fs'; function ourProgram() { enumerateFiles('C:\\'); } withMyLoggingLibrary(() => { withMyFileSystem(() => { ourProgram(); }); });
Unlike async / await or generators, algebraic effects do not require complicating the “intermediate” functions. Our call to enumerateFiles
may be deep inside our program, but as long as somewhere above there is an effect handler for each of the effects that it can execute, our code will continue to work.
Effect handlers allow us to separate the program logic from specific implementations of its effects without unnecessary dances and boilerplate code. For example, we could completely redefine the behavior in the tests to use the fake file system and do snapshots of logs instead of displaying them on the console:
import { withFakeFileSystem } from 'fake-fs'; function withLogSnapshot(fn) { let logs = []; try { fn(); } handle(effect) { if (effect instanceof Log) { logs.push(effect.message); resume; } } // Snapshot . expect(logs).toMatchSnapshot(); } test('my program', () => { const fakeFiles = [ /* ... */ ]; withFakeFileSystem(fakeFiles, () => { withLogSnapshot(() => { ourProgram(); }); }); });
Since functions do not have a “color” (the intermediate code does not have to know about effects), and effect handlers can be composed (they can be nested), you can create very expressive abstractions with them.
Since algebraic effects come from statically typed languages, most of the debate about them focuses on how to express them in types. This is no doubt important, but it can also complicate the understanding of the concept. That's why this article doesn't talk about types at all. However, I should note that usually the fact that a function can perform an effect will be encoded in a signature of its type. Thus, you will be protected from a situation when unpredictable effects are performed, or you cannot track where they come from.
Here you can say that technically algebraic effects “give color” to functions in statically typed languages, since effects are part of a type signature. It really is. However, fixing the type annotation for an intermediate function to include a new effect is not in itself a semantic change — unlike adding async or turning a function into a generator. Type inference can also help to avoid the need for cascading changes. An important difference is that you can “suppress” effects by inserting an empty stub or temporary implementation (for example, a synchronization call for an asynchronous effect), which if necessary allows you to prevent its effect on external code - or turn it into another effect.
Honestly, I do not know. They are very powerful, and it can be argued that they are too powerful for a language such as JavaScript.
I think that they could be very useful for languages where mutability is rare and where the standard library fully supports effects. If you first perform perform Timeout(1000), perform Fetch('http://google.com')
, and perform ReadFile('file.txt')
, and your language has “pattern matching” and static typing for effects, then this can be a very nice programming environment.
Maybe this language will even compile in JavaScript!
Not very big. You can even say that I pull an owl on a globe.
If you watched my talk about Time Slicing and Suspense, then the second part includes components that read data from the cache:
function MovieDetails({ id }) { // ? const movie = movieCache.read(id); }
(The report uses a slightly different API, but that's not the point.)
This code is based on the React function for data samples called Suspense
, which is currently under active development. The interesting thing here, of course, is that the data may not yet be in movieCache - in this case, we need to do something first, because we cannot continue execution. Technically, in this case the call to read () throws Promise (yes, throw Promise - you have to swallow this fact). This pauses execution. React intercepts this Promise and remembers that it is necessary to repeat the rendering of the component tree after the thrown Promise fulfills.
This is not an algebraic effect in itself, although the creation of this trick was inspired by them. This trick achieves the same goal: some code below in the call stack is temporarily inferior to something higher in the call stack (in this case, React), while all intermediate functions do not have to know about it or be “poisoned” by async or generators. Of course, we cannot “actually” resume execution in JavaScript, but from the point of view of React, re-displaying the component tree after Promise permission is almost the same. You can cheat when your programming model assumes idempotency!
Hooks are another example that can remind you of algebraic effects. One of the first questions people ask is: where does the useState call “know” which component it refers to?
function LikeButton() { // useState , ? const [isLiked, setIsLiked] = useState(false); }
I already explained this at the end of this article : in the React object there is a mutable state “current dispatcher”, which indicates the implementation that you are currently using (for example, such as in react-dom
). Similarly, there is a current component property that points to the LikeButton internal data structure. Here's how useState finds out what to do.
Before getting used to it, people often think that it looks like a dirty hack for an obvious reason. It is wrong to rely on a general mutable state. (Note: how do you think try / catch is implemented in the JavaScript engine?)
However, conceptually you can consider useState () as an effect of the execution of State (), which is processed by React when your component is executed. This “explains” why React (what your component calls) can provide it with state (it is higher in the call stack, so it can provide an effect handler). Indeed, explicit state implementation is one of the most common examples in textbooks on algebraic effects that I have encountered.
Again, of course, this is not how React actually works, because we have no algebraic effects in JavaScript. Instead, there is a hidden field in which we save the current component, as well as a field that points to the current "dispatcher" with the useState implementation. As a performance optimization, there are even separate useState implementations for mounts and updates . But if you are now very twisted by this code, then you can consider them ordinary effect handlers.
Summing up, we can say that in JavaScript throw
can work as a first approximation for I / O effects (provided that the code can be safely re-executed later, and until it is tied to the CPU), and the variable field is “ dispatcher "restored in try / finally can serve as a rough approximation for synchronous effects handlers.
You can get a much higher-quality implementation of effects using generators , but this means that you have to abandon the "transparent" nature of JavaScript functions and you have to do everything with generators. And this is “well, that’s ...”
Personally, I was surprised how much sense algebraic effects had for me. I always tried my best to understand abstract concepts, such as monads, but algebraic effects simply took and “turned on” in my head. I hope that this article will help them to “join in” with you.
I do not know whether they will ever begin to be used in bulk. I think that I will be disappointed if they do not take root in any of the main languages by 2025. Remind me to check in five years!
I am sure that much more interesting things can be done with them, but it is really difficult to feel their strength until you start writing code and using them. If this post aroused your curiosity, here are a few more resources where you can read more in detail:
Many people have also pointed out that if you omit the typing aspect (as I did in this article), you can find an earlier use of such a technique in a condition system in Common Lisp. , , call/cc .