TL;DR
Structure Zig projects with modules — use @import for file-based modules, control visibility with pub, and manage packages with build.zig.zon. Free tutorial with examples.
Key concepts
- zig modules
- zig packages
- zig @import
- build.zig.zon
- zig project structure
- zig package manager
- zig dependency management
Packaging and Modules
Zig has no module keyword, no package declaration at the top of a file, and no import paths derived from a directory convention. Instead, every .zig file is a module, and you import it explicitly by path. The result is a system that is simple to reason about: a file's public interface is exactly the set of declarations you mark pub, and the dependency graph of your project is fully described in two files — build.zig and build.zig.zon.
This lesson covers how Zig's module system works from the ground up: how @import resolves files, how to structure a multi-file project, how to expose and consume a public API, and how to declare and fetch external packages using the official package manager.
What You'll Learn
By the end of this lesson you will draw a module boundary and defend it: split a library into a public surface and private internals, decide which declarations get pub, register a named module in build.zig so consumers import it by name rather than by path, and write the build.zig.zon manifest that says what the package is and what it depends on. The test of whether you got it right is not that your own code compiles — it will either way — but what an importer can reach.
This lesson is where the capstone stops being a single file and becomes a project. The Record Analyzer ships with a build.zig.zon naming the package and pinning its minimum Zig version, and its extension list ends by asking you to split it into src/parse.zig, src/stats.zig and src/main.zig wired together with @import — which is exactly the boundary you draw here. You arrive here able to write a build.zig that compiles and tests one module (Build System) and to build types at compile time (Comptime); what is new is that a module turns out to be an ordinary value, and the whole visibility system turns out to be one keyword.
Files Are Modules
In Zig, there is no special syntax to declare a module. Every .zig file is implicitly a struct whose fields are the top-level declarations in that file. When you write @import("std"), you receive a value of type type — a struct containing everything the standard library exposes.
const std = @import("std");
// @import returns a type (a struct). You can inspect it at comptime.
pub fn main(init: std.process.Init) void {
const io = init.io;
// std is a struct — you access its namespaces like fields.
// std.Io.File.stdout() gives the stdout handle; wrap it in a buffered writer.
var buffer: [1024]u8 = undefined;
var file_writer = std.Io.File.stdout().writer(io, &buffer);
const stdout = &file_writer.interface;
// std.math, std.mem, std.fmt are all nested structs (modules)
const max_u32 = std.math.maxInt(u32);
const pi = std.math.pi;
stdout.print("max u32: {}\n", .{max_u32}) catch {};
stdout.print("pi: {d:.5}\n", .{pi}) catch {};
// You can alias namespaces to shorten repeated access
const mem = std.mem;
const haystack = "hello, zig modules";
const found = mem.indexOf(u8, haystack, "zig") != null;
stdout.print("found 'zig': {}\n", .{found}) catch {};
stdout.flush() catch {};
}
The @import builtin accepts either a string literal for a named package (like "std") or a relative file path (like "./geometry.zig"). Named packages are registered through build.zig; file paths are resolved relative to the importing file.
That claim — that a file is a struct, not merely something struct-like — has a consequence worth predicting before you rely on it.
Predict
If @import really returns a struct type, then the ordinary type builtins should work on it. Predict what these three lines do.
const std = @import("std");
pub fn main() void {
// A locally defined namespace, standing in for an imported file.
const geometry = struct {
pub const Point = struct { x: f64, y: f64 };
pub fn origin() Point {
return .{ .x = 0, .y = 0 };
}
};
const alias = geometry;
std.debug.print("{s}\n", .{@typeName(@TypeOf(geometry.origin()))});
std.debug.print("{}\n", .{@TypeOf(alias) == @TypeOf(geometry)});
std.debug.print("{}\n", .{alias.origin().x == geometry.origin().x});
}All three work, and for one reason: a module is an ordinary value whose type is type. That makes const alias = geometry; a plain constant binding rather than a special import form, and it makes the alias the same type — which is why the comparison is true and why re-importing one file from two places gives you one set of types, not two. It is also all that const mem = std.mem; above is doing.
Controlling Visibility with pub
By default, every declaration in a Zig file is private to that file. A function, constant, struct, or type alias is only visible to the module that defines it unless you prefix it with pub. This is the entire visibility system — there is no protected, internal, or package-private.
const std = @import("std");
// Simulating a module's public API inline.
// In a real project, these would live in a separate file
// and be imported with @import("./math_utils.zig").
// Private helper — not accessible to importers
fn clampToRange(value: f64, min: f64, max: f64) f64 {
if (value < min) return min;
if (value > max) return max;
return value;
}
// Public API — importers can call these
pub fn lerp(a: f64, b: f64, t: f64) f64 {
const clamped = clampToRange(t, 0.0, 1.0);
return a + (b - a) * clamped;
}
pub fn inverseLerp(a: f64, b: f64, value: f64) f64 {
if (b == a) return 0.0;
return clampToRange((value - a) / (b - a), 0.0, 1.0);
}
pub const Vec2 = struct {
x: f64,
y: f64,
pub fn length(self: Vec2) f64 {
return std.math.sqrt(self.x * self.x + self.y * self.y);
}
// Private method — only accessible within this struct's methods
fn lengthSquared(self: Vec2) f64 {
return self.x * self.x + self.y * self.y;
}
pub fn normalized(self: Vec2) Vec2 {
const len = self.length();
if (len == 0.0) return self;
return .{ .x = self.x / len, .y = self.y / len };
}
};
pub fn main() void {
const start = Vec2{ .x = 0.0, .y = 0.0 };
const end = Vec2{ .x = 3.0, .y = 4.0 };
std.debug.print("end length: {d:.1}\n", .{end.length()});
std.debug.print("lerp at 0.5: {d:.1}\n", .{lerp(0.0, 10.0, 0.5)});
std.debug.print("lerp clamped: {d:.1}\n", .{lerp(0.0, 10.0, 1.8)});
const mid = Vec2{
.x = lerp(start.x, end.x, 0.5),
.y = lerp(start.y, end.y, 0.5),
};
std.debug.print("midpoint: ({d:.1}, {d:.1})\n", .{ mid.x, mid.y });
_ = end.normalized();
}
A private helper is not merely hidden — it is the thing the public wrapper is supposed to be using. When a public function forgets to call its own helper, the module's surface still looks correct and its behaviour is wrong. Diagnose the library below before changing anything.
Debug
This library exposes a slugify through a clean re-export, and it produces the wrong slug for input with surrounding spaces. The private helper it needs already exists. Diagnose which public function is not using it, then fix the module so the slug comes out as 'build-system'.
const std = @import("std");
fn expect(ok: bool, comptime msg: []const u8) !void {
if (!ok) {
std.debug.print("CHECK FAILED: {s}\n", .{msg});
return error.CheckFailed;
}
}
// The implementation module. In a real project this would be its own file,
// imported as @import("./text.zig").
const text_impl = struct {
// Private helper: strips ASCII whitespace from both ends.
fn trimmed(s: []const u8) []const u8 {
return std.mem.trim(u8, s, " \t\r\n");
}
pub fn slugify(buf: []u8, s: []const u8) []const u8 {
var n: usize = 0;
for (s) |c| {
if (n == buf.len) break;
buf[n] = if (c == ' ') '-' else std.ascii.toLower(c);
n += 1;
}
return buf[0..n];
}
pub fn isBlank(s: []const u8) bool {
return trimmed(s).len == 0;
}
};
// The library root: re-exports the implementation under a clean name.
pub const lib = struct {
pub const text = text_impl;
pub const version = "1.0.0";
};
pub fn main() !void {
var buf: [64]u8 = undefined;
const slug = lib.text.slugify(&buf, " Build System ");
if (!std.mem.eql(u8, slug, "build-system")) {
std.debug.print("CHECK FAILED: slug is '{s}', expected 'build-system'\n", .{slug});
return error.CheckFailed;
}
try expect(lib.text.isBlank(" \t "), "an all-whitespace string is blank");
std.debug.print("All checks passed.\n", .{});
std.debug.print("v{s} slug: {s}\n", .{ lib.version, slug });
}Expected output:
All checks passed. v1.0.0 slug: build-system
The slug came out --build-system--. slugify looped over the raw s, turning the four surrounding spaces into dashes, while isBlank directly below it did call trimmed. Looping over trimmed(s) fixes it. Note what the bug was not: trimmed is private and slugify sits in the same struct, so the call was always permitted — it was simply never made. pub controls who may call a declaration and nothing more; it wires nothing up, and a tidy public surface tells you nothing about whether the code behind it is right.
A useful pattern is re-exporting declarations from within a module. If your top-level main.zig imports a submodule and you want callers to access it through a single import, assign the imported module to a pub const:
const std = @import("std");
// Imagine this is the root of a library.
// It re-exports selected pieces under clean names.
// In reality: pub const geometry = @import("./geometry.zig");
// Here we define a namespace inline to demonstrate the pattern.
pub const geometry = struct {
pub const Point = struct { x: f64, y: f64 };
pub fn distance(a: Point, b: Point) f64 {
const dx = b.x - a.x;
const dy = b.y - a.y;
return std.math.sqrt(dx * dx + dy * dy);
}
};
pub const strings = struct {
pub fn trim(s: []const u8) []const u8 {
return std.mem.trim(u8, s, " \t\n\r");
}
pub fn startsWith(s: []const u8, prefix: []const u8) bool {
return std.mem.startsWith(u8, s, prefix);
}
};
pub fn main() void {
// Callers use: const geo = mylib.geometry;
const a = geometry.Point{ .x = 1.0, .y = 2.0 };
const b = geometry.Point{ .x = 4.0, .y = 6.0 };
std.debug.print("distance: {d:.2}\n", .{geometry.distance(a, b)});
const raw = " hello, zig ";
std.debug.print("trimmed: '{s}'\n", .{strings.trim(raw)});
std.debug.print("starts with 'hello': {}\n", .{strings.startsWith(strings.trim(raw), "hello")});
}
Project Structure and build.zig
A typical multi-file Zig project looks like this:
myproject/
├── build.zig
├── build.zig.zon
└── src/
├── main.zig
├── http.zig
└── config.zig
In build.zig, you register named modules so they can be imported by name rather than by path. This is how "std" works — it is a named package registered by the build system. You can do the same for your own modules:
// build.zig (not runnable in playground — illustrative)
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// Create a named module from a source file
const config_mod = b.addModule("config", .{
.root_source_file = b.path("src/config.zig"),
.target = target,
.optimize = optimize,
});
// Every compilation step takes an explicit root module
const exe = b.addExecutable(.{
.name = "myproject",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "config", .module = config_mod },
},
}),
});
b.installArtifact(exe);
}
Once registered, src/main.zig can write const config = @import("config"); and it resolves to src/config.zig. This decouples import paths from the filesystem layout.
That wiring is also where the most common packaging failure lives, so repair one. Below is a real three-file project whose build.zig is one line short of working, together with the exact error zig build run prints against it.
// build.zig — BROKEN. One thing is missing. Not runnable in the playground.
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const config_mod = b.addModule("config", .{
.root_source_file = b.path("src/config.zig"),
.target = target,
.optimize = optimize,
});
_ = config_mod;
const exe = b.addExecutable(.{ .name = "demo", .root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}) });
b.installArtifact(exe);
const run = b.addRunArtifact(exe);
b.step("run", "run").dependOn(&run.step);
}
src/config.zig declares pub const default: Settings = .{ .name = "app", .port = 8080 };, and src/main.zig opens with const config = @import("config"); and prints those two fields. Running zig build run against this project produces:
src/main.zig:2:24: error: no module named 'config' available within module 'root'
const config = @import("config");
^~~~~~~~
referenced by:
main: src/main.zig:4:37
Repair it before reading on. Two things are worth noticing about that message. It blames src/main.zig, but nothing in src/main.zig is wrong — the file that needs the edit is build.zig, which the error does not name. And the wording is precise: not no file named config but no module named config available within module 'root'. b.addModule("config", ...) created the module and gave it a name; what never happened is telling the root module it may see it. The fix is the .imports list on the executable's module:
const exe = b.addExecutable(.{ .name = "demo", .root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{.{ .name = "config", .module = config_mod }},
}) });
With that line in place, zig build run prints app:8080. The _ = config_mod; discard in the broken version is the tell: it exists only because the module was created and then never used, and the compiler would otherwise have complained about the unused constant. A discard on a module you registered is almost always a missing import.
Two consequences to carry. Import names are per-module, not global — a module can only @import a name that its own .imports list grants, which is why a second executable in the same build.zig needs its own entry even though the module was already created. And the error surfaces at the importing source file, one layer away from the configuration that actually needs the change, so a missing-module error is a signal to open build.zig rather than to re-read the import line.
Note the shape of addExecutable: it takes a .root_module, not a source file. Zig 0.15 removed the implicit root module, so target and optimize are now properties of a module rather than of the compilation step. b.createModule makes a private module for this package; b.addModule makes a named one that dependents can import too. The .imports list wires names to modules up front — the equivalent of calling exe.root_module.addImport("config", config_mod) after the fact, which also still works.
The re-export and the surface it exposes are easiest to feel by assembling them. Assume this module and this buffer are already in scope:
// One private helper behind two public functions — the shape of a real src/text.zig.
const text = struct {
fn trimmed(s: []const u8) []const u8 { return std.mem.trim(u8, s, " \t"); }
pub fn isBlank(s: []const u8) bool { return trimmed(s).len == 0; }
pub fn slugify(buf: []u8, s: []const u8) []const u8 { ... }
};
var buf: [64]u8 = undefined;
The lines below sit inside a function body, where statement order is real — unlike the top-level declarations above, which Zig lets you write in any order at all.
Arrange the code
A text module is already defined above with one private helper and two public functions, and a scratch buffer is declared. The five lines below build a library root that re-exports it, run one string through the public API, and report both the result and the size of the surface an importer would see. The pieces are shuffled. Reorder them so the program prints the slug, the blank check, and the declaration count — then answer what the order is really testing: why is a re-export just a const binding, and why does the same reordering freedom the module declarations above enjoy not apply to these five lines?
const blank = lib.t.isBlank(slug);const decls = if (blank) 0 else @typeInfo(lib.t).@"struct".decls.len;std.debug.print("slug={s} blank={} decls={d}\n", .{ slug, blank, decls });const lib = struct { pub const t = text; };const slug = lib.t.slugify(&buf, " Packaging And Modules ");
The Package Manifest: build.zig.zon
Zig's package manager is built into the toolchain. Dependencies are declared in build.zig.zon — a Zig Object Notation file that describes your package's name, version, and external dependencies:
// build.zig.zon
.{
.name = .myproject,
.version = "0.1.0",
.fingerprint = 0x0,
.minimum_zig_version = "0.16.0",
.dependencies = .{
.zap = .{
.url = "https://github.com/zigzap/zap/archive/refs/tags/v0.9.0.tar.gz",
.hash = "1220...",
},
},
.paths = .{""},
}
Two details of that manifest catch people out. .name is an enum literal — note the leading dot — not a string; writing .name = "myproject" fails with error: expected enum literal. And .fingerprint is required, not optional: omit it and 0.16 refuses to build with error: missing top-level 'fingerprint' field; suggested value: 0x… (the hex differs per invocation, and it is the same value the 0x0 placeholder route hands you). It is a value you never write by hand — start a new package with the 0x0 placeholder above, run zig build once, and the compiler rejects it with error: invalid fingerprint: 0x0; if this is a new or forked package, use this value: 0x..., which is the value you paste in. The 0x... there stands for a real 64-bit literal that the message spells out in full; it is freshly generated on each invocation, so running zig build twice on the same unfixed manifest suggests two different values. Either one is fine — take the first, paste it, and never regenerate it, because the fingerprint is meant to stay constant for the life of the package. The same never-hand-write rule covers .hash, a content hash that zig fetch computes and verifies. Once a dependency is listed, you consume it in build.zig with b.dependency() and attach it to your build target. This two-file design keeps the dependency graph auditable — you can read exactly what is fetched and from where.
One caution before you rely on any of it: this lesson is pinned to Zig 0.16.0, and packaging is among the least settled surfaces in the language. Zig 0.17 is expected to carry breaking changes to package management specifically, alongside changes to b.args — the if (b.args) |args| forwarding you wrote in Build System — and to @bitCast. What holds across the change is the design: a file is a module, pub is the whole visibility system, and the dependency graph lives in exactly two files. What may not hold is the spelling — manifest field names, the fingerprint and hash mechanics, and the b.dependency call shape are all fair game for a release that has already rewritten this area more than once. When 0.17 lands, re-read the manifest section against the release notes before copying any of it into a real project.
Two files, two very different jobs. Check that the distinction is clear, because it rests on things earlier lessons established.
Recall
Without scrolling up: a Zig project's dependency graph lives in build.zig and build.zig.zon. Build System taught what build.zig is, and Comptime taught how the compiler evaluates code before your program runs. Which row correctly divides the labour between the two files?
So the two files split what from how, and data from code. build.zig.zon is Zig Object Notation with no functions and nothing to execute, which is exactly what makes the supply chain auditable at a glance. build.zig is a real program — as Build System established, zig build compiles and runs it — so it can branch on options, register named modules, and pull in a fetched package with b.dependency.
Try It Yourself
Reading about module boundaries is not the same as drawing one. This is a build task: a small program that reports its own pass/fail. You finish a two-module library behind a root that re-exports it — a text module with a private helper behind two public functions, and a config module that validates settings by calling into the text module. Two pieces are stubbed out; the root's re-export is given to you, since the checks name lib.text and lib.config and so cannot compile without it. Run it as-is and it fails immediately, naming the first stub that is still empty. Implement each until it prints All checks passed.
The last check is the interesting one: it reflects on the finished library with @typeInfo(...).@"struct".decls, which lists a struct's public declarations only. That is the module's surface as an importer sees it, so the check passes only if you got the pub markers right and not merely the logic.
One honest limitation to name before you start, because it changes what the exercise can and cannot prove. The playground compiles and runs a single file with zig run, so it cannot create src/text.zig and src/config.zig as separate files on disk, and it cannot invoke zig build to register them as named modules. The starter therefore stands the two modules up as const text_impl = struct { ... } and const config_impl = struct { ... } inside one file. That substitution is exact in the part the exercise tests and inexact in the part it does not. It is exact because a .zig file is a struct — the same struct you get back from @import — so pub governs visibility identically, @typeInfo(...).decls reports the same public surface, and pub const text = text_impl; in the root is character-for-character what pub const text = @import("./text.zig"); reduces to. It is inexact in that main sits in the same file as the modules, which means the compiler will happily let it call a private declaration — the reason the surface check reflects on decls rather than trusting that a call failed to compile. To do the multi-file version for real, create the two files and register them the way the repair exercise above did, with b.addModule plus an .imports entry; everything you write below then moves across unchanged.
Build
Finish the build. Two functions are stubbed out and the checks below them fail until each is right. The library root's re-export is given to you already — read it, because the checks cannot compile without it. Run the starter as-is to see which check fails first, decide what that stub is missing, then implement both until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down. Marking slugify pub is part of the job: the surface check at the bottom fails until you do.
const std = @import("std");
fn expect(ok: bool, comptime msg: []const u8) !void {
if (!ok) {
std.debug.print("CHECK FAILED: {s}\n", .{msg});
return error.CheckFailed;
}
}
// --- The "text" module. In a real project: src/text.zig ---
const text_impl = struct {
// Private helper — importers must not be able to reach this.
fn trimmed(s: []const u8) []const u8 {
return std.mem.trim(u8, s, " \t\r\n");
}
// TODO 1: write `s` into `buf` as a slug and return the written part:
// trim the ends first (the helper above does that), then lowercase
// every character and turn each space into a dash. Stop if buf fills up.
// slugify(&buf, " Build System ") -> "build-system"
// This must be part of the module's PUBLIC surface.
fn slugify(buf: []u8, s: []const u8) []const u8 {
_ = buf;
_ = s;
return "";
}
pub fn isBlank(s: []const u8) bool {
return trimmed(s).len == 0;
}
};
// --- The "config" module. In a real project: src/config.zig ---
const config_impl = struct {
pub const Settings = struct { name: []const u8, port: u16 };
pub const default: Settings = .{ .name = "app", .port = 8080 };
// TODO 2: reject bad settings. Return error.InvalidPort when port is 0,
// and error.MissingName when the name is blank — reuse the text module's
// public isBlank rather than re-implementing the whitespace test.
// Valid settings return normally.
pub fn validate(s: Settings) !void {
_ = s;
}
};
// --- The library root. In a real project: src/root.zig ---
// The re-export, given to you because the checks below cannot even compile
// without it: both implementation modules bound under clean names, so a caller
// writes lib.text.slugify(...) and lib.config.validate(...). Read it before you
// start — a module is an ordinary value of type `type`, which is why a plain
// `pub const` binding is the whole of a re-export, exactly as
// `pub const geometry = @import("./geometry.zig");` would be in a real project.
pub const lib = struct {
pub const version = "1.0.0";
pub const text = text_impl;
pub const config = config_impl;
};
// --- Build checks: Do not edit below this line. ---
pub fn main() !void {
var buf: [64]u8 = undefined;
try expect(std.mem.eql(u8, lib.text.slugify(&buf, "Build System"), "build-system"), "slugify should lowercase and dash-separate");
try expect(std.mem.eql(u8, lib.text.slugify(&buf, " Build System "), "build-system"), "slugify should trim the ends BEFORE dashing spaces");
try expect(lib.text.isBlank(" \t "), "an all-whitespace string is blank");
try expect(!lib.text.isBlank(" x "), "a string with a non-space character is not blank");
try lib.config.validate(lib.config.default);
var bad_port = lib.config.default;
bad_port.port = 0;
if (lib.config.validate(bad_port)) |_| {
try expect(false, "port 0 should be rejected");
} else |err| {
try expect(err == error.InvalidPort, "port 0 should give error.InvalidPort");
}
var bad_name = lib.config.default;
bad_name.name = " ";
if (lib.config.validate(bad_name)) |_| {
try expect(false, "a blank name should be rejected");
} else |err| {
try expect(err == error.MissingName, "a blank name should give error.MissingName");
}
// The public surface, as an importer sees it: decls lists pub declarations only.
comptime var public_count = 0;
comptime var saw_slugify = false;
comptime var saw_trimmed = false;
inline for (@typeInfo(lib.text).@"struct".decls) |d| {
public_count += 1;
if (comptime std.mem.eql(u8, d.name, "slugify")) saw_slugify = true;
if (comptime std.mem.eql(u8, d.name, "trimmed")) saw_trimmed = true;
}
try expect(saw_slugify, "slugify must be pub — it is part of the module's surface");
try expect(!saw_trimmed, "trimmed must stay private — it should not appear in the surface");
try expect(public_count == 2, "the text module should expose exactly two declarations: slugify and isBlank");
std.debug.print("All checks passed.\n", .{});
std.debug.print("v{s} slug: {s}\n", .{ lib.version, lib.text.slugify(&buf, "Packaging And Modules") });
std.debug.print("public declarations in lib.text: {d}\n", .{public_count});
}Expected output:
All checks passed. v1.0.0 slug: packaging-and-modules public declarations in lib.text: 2
Once it passes, try two variations and predict each before running:
- Take the
puboffslugify. Predict which check fires first. The logic checks at the top still pass —mainis in the same file, so it may call a private declaration — butsaw_slugifybecomes false andpublic_countdrops to 1, so the surface checks fail. That gap between "my code still compiles" and "importers can still use this" is precisely whatpubgoverns, and why reflecting ondeclsis a sharper test of a module boundary than calling the function is. - Re-implement the blank test inside
validate. Replacetext_impl.isBlank(s.name)withs.name.len == 0and predict the outcome. Thebad_namecheck fails, because" "has length 3 — a whitespace-only name slips through. The duplicated test drifted from the real one immediately; calling the text module's public function is what keeps the two definitions of "blank" identical.
The exercise below simulates the same idea at a larger scale — a small library with a clean public API. Explore how the pub keyword controls what is accessible, and how namespacing avoids name collisions:
const std = @import("std");
// --- Simulated "config" module ---
const config = struct {
pub const AppConfig = struct {
host: []const u8,
port: u16,
max_connections: u32,
debug: bool,
};
pub const default: AppConfig = .{
.host = "127.0.0.1",
.port = 8080,
.max_connections = 100,
.debug = false,
};
pub fn validate(cfg: AppConfig) !void {
if (cfg.port == 0) return error.InvalidPort;
if (cfg.max_connections == 0) return error.InvalidConnections;
}
};
// --- Simulated "logger" module ---
const logger = struct {
const Level = enum { debug, info, warn, err };
// Private — callers cannot access this directly
fn levelLabel(level: Level) []const u8 {
return switch (level) {
.debug => "DEBUG",
.info => "INFO ",
.warn => "WARN ",
.err => "ERROR",
};
}
pub fn log(level: Level, comptime fmt: []const u8, args: anytype) void {
std.debug.print("[{s}] " ++ fmt ++ "\n", .{levelLabel(level)} ++ args);
}
pub fn info(comptime fmt: []const u8, args: anytype) void {
log(.info, fmt, args);
}
pub fn warn(comptime fmt: []const u8, args: anytype) void {
log(.warn, fmt, args);
}
};
pub fn main() !void {
// Use the config module
var cfg = config.default;
cfg.host = "0.0.0.0";
cfg.port = 9090;
cfg.debug = true;
try config.validate(cfg);
logger.info("Server starting on {s}:{}", .{ cfg.host, cfg.port });
logger.info("Max connections: {}", .{cfg.max_connections});
if (cfg.debug) {
logger.warn("Debug mode is enabled", .{});
}
// Demonstrate error from validation
var bad = config.default;
bad.port = 0;
config.validate(bad) catch |err| {
logger.warn("Config invalid: {}", .{err});
};
logger.info("Startup complete", .{});
}
Zig is not the only language that makes a module's surface an explicit decision. Check which half of the idea travels.
Transfer
In Zig a declaration is private to its file unless you write pub, and that is the entire visibility system — there is no second level. Rust also defaults to private and also spells the opt-out pub, but adds graded forms: pub(crate) for the whole crate, pub(super) for the parent module, pub(in path) for a named subtree. Which statement names what the two languages genuinely share, rather than the shared spelling of one keyword?
Key Takeaways
- Every
.zigfile is implicitly a module (a struct).@importreturns that struct as atypevalue. @importaccepts either a string name for a registered package or a relative file path like"./utils.zig".- All top-level declarations are private by default. Only
pubdeclarations are accessible to importers. - Structs can have
pubmethods alongside private helpers — privacy applies at method level too. - Named modules are registered in
build.zigusingb.addModule()and wired into a consumer through its.importslist (or withaddImport()after the fact). - External dependencies are declared in
build.zig.zonwith a URL and content hash, and fetched withzig fetch. - Re-exporting submodules as
pub const name = @import("./name.zig");gives callers a single entry point. - The entire dependency graph is auditable from two files:
build.zig(how to build) andbuild.zig.zon(what to fetch).
Pro Tip: Keep your library's public surface small and deliberate. Start every declaration as private and only add
pubwhen you have a clear reason for a caller to need it. Unexported internals can change freely without breaking consumers — public API is a contract you must maintain. A module with tenpubdeclarations and thirty private helpers is easier to evolve than one where everything is exposed.
Next Steps
Your project now builds, tests, and packages cleanly — every piece of the toolkit is in place. Next comes the capstone: a single command-line utility that puts parsing, allocators, data structures, error pipelines, and formatted output together into one real, tested program.
Ready to try it? Continue in your learning studio to practice these ideas, save your progress, and experiment with code.