homedark

In Zig, What's a Writer?

Jan 28, 2025

I find Zig's idea of a "writer" confusing. This is probably because there are three different types, each trying to compensate for compromises made by the others. Let's try to understand what each is and how it fits into a bigger whole.

The first writer that you're likely to run into is a writer: anytype, with the most visible cases found in the std.fmt package, i.e. std.fmt.print.

I've written about anytype before. As a quick recap, think of it as a template. For example, if we have this code:

fn writeN(writer: anytype, data: []cont u8, n: usize) !void {
    var i: usize = 0;
    while (i < n) : (i += 1) {
        try writer.writeAll(data)
    }
}

A copy of this function will be created for every type of writer that is used. Given this invocation:

var logger = MyLogger{};
try writeN(logger, ".", 10);

We'd end up with this copy of writeN:

// anytype -> MyLogger
fn writeN(writer: MyLogger, data: []cont u8, n: usize) !void {
    var i: usize = 0;
    while (i < n) : (i += 1) {
        try writer.writeAll(data)
    }
}

If MyLogger didn't implement the necessary writeAll([]const u8) !void method, we'd get a compiler error - just like we'd expect if we wrote the writeN(writer: MyLogger, ...) function ourselves.

anytype is super useful and has zero runtime overhead. But there are a few downsides. First, it can make binaries larger and compilation slower. In most cases, there's ever only one or maybe a few different types that are used, so, it isn't an issue. Second, it's a documentation black hole. A function that takes a writer: anytype likely expects one or many of the following methods:

write(data: []const u8) !void
writeAll(data: []const u8) !void
writeByte(b: u8) !void
writeByteNTimes(b: u8, n: usize) !void
writeBytesNTimes(data: []const u8, n: usize) !void

But this is just a convention based on the fact that the parameter name is writer. You either have to go through the source code and see how writer is used, or let the compiler tell you which function is expected.

But the main issue with anytype is that it can only be a used as a function parameter. This isn't valid:

const Opts = struct {
    output: anytype
}

For that, we need something else.

The std.io.AnyWriter type is the closest thing Zig has to a writer interface. We've covered Zig interfaces before and AnyWriter is essentially the simplest version we looked at, i.e.:

pub const AnyWriter = struct {
  context: *const anyopaque,
  writeFn: *const fn (context: *const anyopaque, bytes: []const u8) anyerror!usize,

    pub fn write(self: AnyWriter, bytes: []const u8) anyerror!usize {
        return self.writeFn(self.context, bytes);
    }
};

Unlike other languages where interfaces are purely a contract with no implementation, Zig tends to stuff a lot of behavior into its interfaces. For example, AnyWriter implements writeAll which relies on the above write function, and writeByteNTimes which relies on writeAll:

pub fn writeAll(self: AnyWriter, bytes: []const u8) anyerror!void {
    var index: usize = 0;
    while (index != bytes.len) {
        index += try self.write(bytes[index..]);
    }
}

pub fn writeByteNTimes(self: AnyWriter, byte: u8, n: usize) anyerror!void {
    var bytes: [256]u8 = undefined;
    @memset(bytes[0..], byte);

    var remaining: usize = n;
    while (remaining > 0) {
        const to_write = @min(remaining, bytes.len);
        try self.writeAll(bytes[0..to_write]);
        remaining -= to_write;
    }
}

Now this approach can have performance issues, since there's no way for an implementation to provide, for example, an optimized writeByteNTimes. Still, AnyWriter fills that gap around the limitations of anytype's usage.

It would be reasonable to think that when you call file.writer() or array_list.writer(), you're getting an std.io.AnyWriter interface. In reality though, you're getting a std.io.GenericWriter, which std.io.Writer aliases. To understand what this type is, we need to look at the writeFn field of AnyType:

*const fn (context: *const anyopaque, bytes: []const u8) anyerror!usize

Specifically, notice the anyerror return type. Unlike an inferred error type (i.e. !usize) which will implicitly create an errorset based on the function's possible error values, anyerror is an implicitly created errorset for the entire project. This means that even though your specific writer's write function might only be able to return an error.OutOfMemory, the AnyError interface will expose any possible error your program might return. In many cases, that won't be an issue. But projects with strict reliability requirements might need/want to handle every error explicitly, especially when we're talking about something like writing data. Think of a database persisting a WAL file to disk, for example.

Thus we havestd.io.GenericWriter which, as part of its generic contract, takes an error type. Here's what the generic parameters look like:

pub fn GenericWriter(
    comptime Context: type,
    comptime WriteError: type,
    comptime writeFn: fn (context: Context, bytes: []const u8) WriteError!usize,
) type {
    ....
}

Notice that the writeFn's return value is now a typed error - with the type being provided by the implementation.

Let's look at some examples. Here's what an implementation that returns an AnyWriter might look like:

pub const DummyWriterAny = struct {
    fn write(_: *const anyopaque, data: []const u8) error{OutOfMemory}!usize {
        _ = data;
        return error.OutOfMemory;
    }

    pub fn writer(self: *DummyWriterAny) std.io.AnyWriter {
        return .{
            .context = self,
            .writeFn = write,
        };
    }
};

Even though our write function returns an explicit error, that type information is lost when we convert our DummyWriterAny to a AnyWriter. Here's a similar implementation but for a GenericWriter:

pub const DummyWriterGen = struct {
    fn write(_: *DummyWriterGen, data: []const u8) error{OutOfMemory}!usize {
        _ = data;
        return error.OutOfMemory;
    }

    pub const Writer = std.io.Writer(*DummyWriterGen, error{OutOfMemory}, write);

    pub fn writer(self: *DummyWriterGen) Writer {
        return .{.context = self};
    }
};

Now when we convert our DummyWriterGen to an std.io.GenericWriter, the error type is preserved.

However, it's important to realize that GenericWriter isn't just a better, more type-aware, version of AnyWriter. One is a generic the other is an interface. Specifically, a GenericWriter for a File is a different type than a GenericWriter for an ArrayList(u8). It isn't an interface and can't be used like one.

For everyday programming, what all of this means is that if you have a File, ArrayList(u8), Stream or any other type which has a writer method, you're almost certainly getting an GenericWriter. This writer can usually be passed to a function with a writer: anytype:

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();
    var arr = std.ArrayList(u8).init(allocator);

    // The first parameter to format is a writer: anytype
    // we can pass our arr.writer() to it.
    try std.fmt.format(arr.writer(), "over {d}!!", .{9000});

    std.debug.print("{s}\n", .{arr.items});
}

I say "usually", because there's no guarantee; it relies on all of us agreeing that a variable named writer of type anytype only ever uses methods available to a GenericWriter. Sarcasm aside, it does mostly work.

For cases where an std.io.AnyWriter is needed, such as storing a implementation-independent writer in a struct field, you'll need to use an AnyWriter, which you can easily get by calling any() on your GenericWriter:

// a slightly dumb example
const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    var arr = std.ArrayList(u8).init(allocator);
    const opts = Opts{
        .output = arr.writer().any(),
    };
    try write("hello world", opts);
}

const Opts = struct {
    output: std.io.AnyWriter,
};

fn write(data: []const u8, opts: Opts) !void {
    _ = try opts.output.write(data);
}

If I understand correctly, the motivation for this design, was reducing code bloat while providing a mechanism to preserve typed errors. This is possible because GenericWriter relies on the various methods of AnyWriter, like writeAll and writeByteNTimes. So while there will be many copies of GenericWriter (for File, Stream, ArrayList(u8), etc.), they each have a very small functions which only invoke the AnyReader logic and re-type the error. For example, here's GenericWriter.writeAll:

pub inline fn writeAll(self: Self, bytes: []const u8) Error!void {
    return @errorCast(self.any().writeAll(bytes));
}

We see that @errorCast does the heavy lifting, converting the anyerror that AnyWriter.writeAll returns into the narrow type-specific error for this implementation.

Like I said, for everyday programming, you'll mostly be passing the result of writer() to a writer: anytype function parameter. And it mostly works, possibly after you've wasted time trying to figure out exactly what the requirements for the writer are. It's only when you can't use anytype, i.e. in a structure field, that this GenericWriter / AnyReader chimera becomes something you need to be aware of.

Hopefully the situation can be improved, specifically with some of the performance issues and resulting poor documentation.