Zig 0.16 Notes
Native SDK apps are written in Zig, and the toolkit requires Zig 0.16.0. You rarely install it yourself: native dev, native build, and native test use the zig on your PATH when its version matches, and otherwise offer to download the pinned version into ~/.native/toolchains/ (checksum-verified; --yes skips the prompt in scripts). native doctor reports whether a zig command is available, and the SDK's build.zig.zon declares minimum_zig_version = "0.16.0", so an incompatible compiler fails loudly rather than mysteriously.
The catch: most Zig tutorials, blog posts, and AI training data teach Zig 0.15 or earlier, and 0.16 reshaped the standard library. Code written from that memory fails with no member named errors on APIs that moved. This page maps each error to the current idiom. Coding agents get the same content (plus references into the SDK source) from the CLI: native skills get zig.
The one big idea
Zig 0.16 moved everything that touches the outside world — files, stdout, clocks, sleeping, child processes, sockets — behind an explicit std.Io value that you pass in. Your main receives one (along with allocators, the environment, and the args) through std.process.Init; tests use std.testing.io. The second change: containers like ArrayList no longer remember their allocator — you pass it to every call.
Inside a Native SDK app, you mostly don't need std.Io at all: update does files, subprocesses, HTTP, and timers through the typed effects channel (fx.readFile, fx.spawn, fx.fetch, fx.startTimer), and clock reads go through fx.wallMs() or native_sdk.nowMs(). Raw std.Io shows up in main, in tests, and in standalone tools.
Error-to-fix index
| The compiler says | What changed |
|---|---|
struct 'heap' has no member named 'GeneralPurposeAllocator' | main receives std.process.Init |
struct 'fs' has no member named 'cwd' | File IO lives on std.Io.Dir |
struct 'std' has no member named 'io' | Printing and writers |
struct 'array_list.Aligned(u8,null)' has no member named 'init' | ArrayList is unmanaged |
invalid format string 's' for type '...' | Format specifiers {t} and {f} |
struct 'time' has no member named 'sleep' (or 'milliTimestamp') | Time and sleeping |
struct 'process.Child' has no member named 'init' | Child processes |
struct 'process' has no member named 'getEnvMap' (or 'argsAlloc') | Environment and arguments |
struct 'std' has no member named 'net' | Sockets moved to std.Io.net (IpAddress, Stream) and take an io |
struct 'fs' has no member named 'selfExePath' | std.process.executablePath(io, &buf), returning the length |
no field named 'root_source_file' in struct 'Build.ExecutableOptions' | build.zig artifacts take modules |
main receives std.process.Init
There is no allocator boilerplate anymore. main takes std.process.Init, which carries a leak-checked general-purpose allocator, a process-lifetime arena, the Io, the environment, and the command-line arguments:
pub fn main(init: std.process.Init) !void {
const gpa = init.gpa;
const arena = init.arena.allocator();
const io = init.io;
const args = try init.minimal.args.toSlice(arena);
_ = args;
_ = gpa;
_ = io;
}The main that native init generates already has this shape — it passes init through to the runner and init.io to the hot-reload watcher. If you write a helper that needs to do IO, take an io: std.Io parameter and thread it down from main.
File IO lives on std.Io.Dir
std.fs.cwd() and std.fs.File are gone. The handle is std.Io.Dir, and every operation takes io right after the receiver. Note the readFileAlloc argument order and the explicit size limit:
const cwd = std.Io.Dir.cwd();
const content = try cwd.readFileAlloc(io, "notes.txt", allocator, .limited(1024 * 1024));
defer allocator.free(content);
try cwd.writeFile(io, .{ .sub_path = "notes.txt", .data = bytes });
var file = try cwd.openFile(io, "notes.txt", .{});
defer file.close(io);Inside update, don't do this at all — fx.readFile and fx.writeFile persist app state through the effects channel and deliver the result as a message.
Printing and writers
std.io.getStdOut().writer() is gone. Stdout takes io plus a buffer you own, printing goes through the .interface field, and nothing appears until you flush:
var stdout_buffer: [4096]u8 = undefined;
var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buffer);
const stdout = &stdout_writer.interface;
defer stdout.flush() catch {};
try stdout.print("{s}\n", .{message});For debug output, std.debug.print is unchanged and needs no io. std.io.fixedBufferStream became std.Io.Writer.fixed(&buf) (read the result back with .buffered()), and growable string building is std.Io.Writer.Allocating.init(allocator). One-shot formatting keeps std.fmt.bufPrint and std.fmt.allocPrint, unchanged.
ArrayList is unmanaged
The list no longer stores its allocator. Start from .empty and pass the allocator to every call that can allocate or free:
var list: std.ArrayList(Habit) = .empty;
defer list.deinit(allocator);
try list.append(allocator, habit);
try list.appendSlice(allocator, more_habits);
const owned = try list.toOwnedSlice(allocator);The old std.ArrayList(T).init(allocator) fails with the no member named 'init' error above; a managed-style list.append(item) fails with member function expected 2 argument(s), found 1. (std.StringHashMap and std.AutoHashMap still have managed .init(allocator) forms — only ArrayList and explicitly Unmanaged types changed.)
Format specifiers {t} and {f}
{s} formats strings only. Printing an enum or tagged union tag is {t}:
std.debug.print("filter is {t}\n", .{model.filter}); // prints the tag nameCustom format methods changed twice over: the old four-parameter signature (comptime fmt, FormatOptions, anytype writer) no longer compiles, and {} no longer calls a format method — it prints the raw field dump. Declare the new two-parameter signature and print with {f}:
pub fn format(self: Point, writer: *std.Io.Writer) std.Io.Writer.Error!void {
try writer.print("({d},{d})", .{ self.x, self.y });
}
std.debug.print("{f}\n", .{point});Time and sleeping
std.time keeps only unit constants (ns_per_ms and friends); reading clocks and sleeping take io:
try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(50), .awake);
const now_ns: i128 = std.Io.Timestamp.now(io, .real).nanoseconds;App code should not reach for either: native_sdk.nowMs() and native_sdk.monotonicMs() are the SDK's clock facade, fx.wallMs() is the journaled read inside update, and repeating work belongs to fx.startTimer. See State & Data Flow.
Child processes
Child.init plus child.spawn() collapsed into one call, and lifecycle methods take io:
var child = try std.process.spawn(io, .{
.argv = &.{ "git", "status", "--porcelain" },
.stdin = .ignore,
.stdout = .inherit,
.stderr = .inherit,
});
const term = try child.wait(io);Inside an app, use fx.spawn instead — bounded, replay-friendly, and the exit and output arrive as messages.
Environment and arguments
std.process.getEnvMap, argsAlloc, and argsWithAllocator are gone; main already holds both ends: init.environ_map.get("HOME") and init.minimal.args.toSlice(allocator). Building an environment for a child process is std.process.Environ.Map.init(allocator) plus put.
build.zig artifacts take modules
Zero-config apps have no build.zig — this only matters after native eject or native init --full. Executables and tests take a root_module, and the module owns the root source file, target, and optimize mode:
const exe = b.addExecutable(.{
.name = "app",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
});The ejected build.zig the CLI writes is the reference shape.
Tests get their Io from std.testing.io
Tests never construct an Io; std.testing.io sits next to std.testing.allocator:
test "round-trips the store" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "store.txt", .data = "v1" });
}Model/Msg/update tests — the ones the generated src/tests.zig shows — need no Io at all.
One more habit: build and test both
Zig analyzes code lazily, so an outdated idiom in a function only tests reference (or only main references) can hide from the other command indefinitely. Run both native build and native test (or zig build and zig build test) before calling a change done.
What didn't change
std.fmt.bufPrint / allocPrint / parseInt / parseFloat, std.mem, std.debug.print, std.heap.page_allocator, the std.time unit constants, @embedFile, and std.testing.expect* all work as before. If code using only these fails, the problem is elsewhere.