No need to return errors; carve holes instead
Intro
Have you ever used Result in Rust and is bothered by it not carrying enough debug information? Have you used errors in Zig and is bothered by it not having data fields?
Chances are, all paths besides the happy path can be written separately from the happy path.
In this article, we use Zig, not Koka, for demostration.
Warning: stack overflow
Try to compile the following code with Zig 0.17.x (currently the master branch).
const std = @import("std");
pub fn main() !void {
const x = entry(effect_handler, 0);
std.debug.print("Done: {}\n", .{x});
}
fn entry(h: fn (i32) i32, x: i32) i32 {
return h(x);
}
fn effect_handler(x: i32) i32 {
if (x == 10000000) {
return std.process.abort();
} else {
return entry(x + 1);
}
}
In Debug mode, the program stack overflows, because there is no tail call optimization.
In ReleaseFast mode, the program terminates with SIGABRT (the correct behavior).
Using effect handlers in programs might cause it to stack overflow. This is a limitation of the hardware and the compiler, not a limitation of the theory.
Carving a hole
Let’s say you have the following code:
const std = @import("std");
pub fn main() !void {
var i: i32 = 0;
while (i < 500) : (i += 1) {
const output = some_task();
std.debug.print("{}\n", .{output});
}
}
fn some_task() i32 {
if (some_io_succeeds()) {
return 42;
} else {
// todo
}
}
var rng = std.Random.DefaultCsprng.init("hello there");
// For demostration only.
// Real applications interact with the outside world,
// and is affected by that
fn some_io_succeeds() bool {
return rng.random().boolean();
}
When the I/O fails, you don’t know what to do. (denoted by // todo)
You might have learned that you should return an error here. But then, you need to think of an error message. That’s too much work. Instead, you ask someone else to make up a number and return it.
fn some_task(someone_else: fn() i32) i32 {
if (some_io_succeeds()) {
return 42;
} else {
return someone_else();
}
}
You have now carved a hole in some_task.
You don’t have to finish computation
In an effect handler, it is possible to just abort and ignore what comes after the handler.
fn someone_else_impl() i32 {
return std.process.abort();
}
This technique is more useful when you do something first before abort().
Group holes
You can do this, I believe in you!
const Holes = struct {
someone_else: fn () i32,
alice: fn () bool,
bob: fn (x: i32) void,
};
fn some_other_task(holes: Holes) void {
_ = holes; // use holes
}
What is this
Above, Holes is an “effect”. I first came to know about effects when I met Koka. Still, it’s easy to implement effects and effect handlers in many languageswhere it is not designed as a language feature.
About resource management
If not careful, it’s easy to forget finalizing objects in Zig. One way to solve this is to write code in continuation-passing style – by passing all live objects to each event handler, given the handler the option to finalize objects.
When using effect handlers, you would have a much better time using a language that tracks resources for you. Zig doesn’t track resources on the language level. You would have to pair it with a static analyzer like opengrep. I chose Zig in this article mainly because Rust’s compiler is slower, and Go doesn’t have sum types. If the Crystal compiler isn’t slow, I would probably use that.
Finally, don’t be afraid to use mathematical techniques like this in your code!
Update(2026-09-17): I have written a program in this style, using Zig! https://git.sr.ht/~iacore/porkbun-cli/ Turns out it is not convoluted as I previously thought. Clever use of the type system is important.