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
:
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);
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
:
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.