Background

Blog post What Color is Your Function? (2015) by Bob Nystrom highlighted problems of async computation handling in programming language design. It has started heated discussions on Hacker News and Reddit.

Although many solutions to this problem were suggested, none of them seemed to be a silver bullet.

Zig’s new I/O

In the Zig Roadmap 2026 stream Andrew Kelley announced a new way of doing I/O, and Loris Cro wrote a Zig’s New Async I/O blog post describing it in more details.

This is the example Zig code from that post, that writes data to two files asynchronously:

const std = @import("std");
const Io = std.Io;

fn saveData(io: Io, data: []const u8) !void {
   var a_future = io.async(saveFile, .{io, data, "saveA.txt"});
   defer a_future.cancel(io) catch {};

   var b_future = io.async(saveFile, .{io, data, "saveB.txt"});
   defer b_future.cancel(io) catch {};

   // We could decide to cancel the current task
   // and everything would be released correctly.
   // if (true) return error.Canceled;

   try a_future.await(io);
   try b_future.await(io);

   const out: Io.File = .stdout();
   try out.writeAll(io, "save complete");
}

fn saveFile(io: Io, data: []const u8, name: []const u8) !void {
   const file = try Io.Dir.cwd().createFile(io, name, .{});
   defer file.close(io);
   try file.writeAll(io, data);
}

Loris later claims that this approach to I/O solves function coloring… and I don’t agree.

To see why, let’s compare function signatures of red (blocking) and blue (non-blocking) versions of saveData with a few modifications that make my argument a bit more clear:

// red
fn saveData(data: []const u8) !void
fn saveFile(file: std.File, data: []const u8) !void

// blue
fn saveData(io: Io, data: []const u8) !void
fn saveFile(io: Io, file: std.File, data: []const u8) !void

And compare it to semantically similar functions in Node.js:

// red
function saveData(data: Uint8Array): void
function saveFile(file: string, data: Uint8Array): void

// blue
async function saveData(data: Uint8Array): Promise<void>
async function saveFile(file: string, data: Uint8Array): Promise<void>

Difference is easy to spot:

But there is a catch: with this new I/O approach it is impossible to write to a file without std.Io!

// impossible without io
fn saveData(data: []const u8) !void

Semantically, passing std.Io to every function is no different from making every Node.js function async and returning a promise. Zig shifts function coloring from blocking/non-blocking choice to io/non-io.

Inevitable?

So does Zig solve a function coloring problem? Depends on who you ask:

In my opinion, function coloring is not about the syntax, not about the function’s type signature - it is about semantics and behavior of the function. And I don’t think this problem is solvable:

Does this mean that the programming language needs to treat them in the same way?

Ergonomics

But most complains related to function coloring are not that blocking and non-blocking functions need to be handled differently. Complains are about how inconvenient it is to work with these differences. Understanding this moves the problem from computer science to the programming language design - how to make it more convenient?

And this is where I think Zig’s new I/O design does a great job. It unifies the way of working with both execution models elegantly. Although passing std.Io everywhere seems to be annoying, it seems to work well for passing std.mem.Allocator to any function that allocates. It keeps intent clear and gives great flexibility to the caller.

And as with allocation, not every bit of behavior needs to be expressed in the type signature: function does not tell who owns allocated memory, developer either needs to read the docs or go through the code to find out for themselves.

Further reading