platypro.net
Implementing Monads in zig
April 25, 2026
I have been really enjoying the new async/await system added into zig 0.16.0; The most interesting aspect of it to me is how it has been implemented entirely within the standard library without needing to introduce any syntax. The focus of this article however, is an alternative interface to the async/await constructs inspired by functional programming languages such as Haskell. Why? No reason at all. Let us begin.<br>A Functional Perspective<br>A core tenet of functional programming is the notion of referential transparency. In a functional programming language, a function called with the same input will always return the same output. This quickly becomes an issue though, since many functions of our program may rely on values external to it. We can see this below when generating a random number:<br>const std = @import("std");
/// Generate a random number and return it<br>fn random(io: std.Io) std.Io.RandomSecureError!u32 {<br>var result: u32 = undefined;<br>try io.randomSecure(@ptrCast(&result));<br>return result;
pub fn main(init: std.process.Init) !void {<br>for (0..5) |_| {<br>std.debug.print("{x:08}\n", .{try random(init.io)});<br>Now let's run it:<br>$ zig run ex_random.zig<br>f95b7fcb<br>a021349f<br>cf019603<br>8de341f5<br>42a6107d<br>We called random with the same arguments but got different results! So there is no referential transparency here. The result of random() is no longer dependent on solely on what is passed into it, but also on external state out of our control. Additionally, since random() does not have referential transparency, and main() calls random(), main() also does not have any referential transparency. Running main() more than once may produce different results. From now on we are going to be calling functions with referential transparency pure , and those without impure . The rest of this article explores how we can avoid impure functions using a structure called Monad. But first, an exploration on the new async/await in zig 0.16.0<br>Futures<br>First let us explore a new structure introduced in zig 0.16.0: std.Io.Future(Result). Here is the abridged definition as it appears in the standard library:<br>pub fn Future(Result: type) type {<br>return struct {<br>any_future: ?*AnyFuture,<br>result: Result,
// "cancel: fn (*@This(), Io) Result" excluded for brevity
/// Idempotent. Not threadsafe.<br>pub fn await(f: *@This(), io: Io) Result {<br>const any_future = f.any_future orelse return f.result;<br>io.vtable.await(<br>io.userdata,<br>any_future,<br>@ptrCast(&f.result),<br>.of(Result),<br>);<br>f.any_future = null;<br>return f.result;<br>};
The type function for std.Io.Future(Result) takes another type as an argument. This is the type of value which the Future represents. A instance of std.Io.Future(Result) has two fields: a field of type Result and a field of type ?*AnyFuture. The magic of std.Io.Future(Result) is that the result contained within may not be known yet. If the result is unknown, the any_future field is non-null and the result field is undefined. Once the result becomes known, the any_future field becomes null, and result contains a value.<br>To get the value out of the future, whether it has been computed yet or not, we can call the future.await() member function. Firstly, future.await() checks if any_future is null. If it is null, that means the result field already contains a value, so we just return it. If any_future is non-null, result is undefined and we must pass any_future into io.vtable.await() in order to receive that value. Since io.vtable.await() sets the result field itself, the instance of std.Io.Future(Result) must be mutable and passed as a pointer. Now that result is set, any_future is set to null which makes any future calls to future.await() simply read the result again.<br>Something important to keep in mind with Future is that since any_future is a pointer, it is pointing to external state. A value of type *AnyFuture must only be used as an argument to io.vtable.await() once. Calling await with the same *AnyFuture a second time is undefined behavior. Since the pointer is part of the std.Io.Future(Result) struct, we must be careful about ever making a copy of a std.Io.Future(Result) instance. If any_future is null, this operation is safe, as there is no *AnyFuture, so no risk of re-use. If however any_future is non-null, calling future.await() will invalidate any other copies of the Future. Since there is always the possibility of copies being invalidated, the code must make clear whenever making a copy that the old instance of future must not be used.<br>A question that arises now, is how do we get a value of type *AnyFuture? As we saw inside of future.await(), we use a std.Io instance to get the result out of an *AnyFuture. To create a future, we also use a std.Io instance, this time calling io.vtable.async(). The best way to show how io.vtable.async() is used, is to see how it is used in the standard library. Below is the annotated source code for io.async():<br>pub fn async(<br>/// an instance...