Overview
Chapter 19 mapped the compiler’s module graph; this chapter names the roles those modules can play so you know when a file is merely a helper, when it graduates to a program, and when it becomes the nucleus of a reusable package or library.
We will also preview how the Zig CLI registers modules for consumers, setting the stage for build graph authoring in Chapter 21 and in build.zig.
Learning Goals
- Distinguish modules, programs, packages, and libraries, and explain how Zig treats each during compilation.
- Use the
--depand-Mflags (and their build graph equivalents) to register named modules for consumers. - Apply a practical checklist for picking the right unit when starting a new artifact or refactoring an existing one. 19
Building a shared vocabulary
Before you wire build scripts or register dependencies, settle on consistent language: In Zig, a module is any compilation unit returned by @import, a program is a module graph with an entry point, a package bundles modules plus metadata, and a library is a package intended for reuse without a root main.
start.zig
Modules and programs in practice
This demo starts with a root module that exports a manifest for a library but also declares main, so the runtime treats the graph as a program while the helper module introspects public symbols to keep terminology honest. 19
// This module demonstrates how Zig's module system distinguishes between different roles:
// programs (with main), libraries (exposing public APIs), and hybrid modules.
// It showcases introspection of module characteristics and role-based decision making.
const std = @import("std");
const roles = @import("role_checks.zig");
const manifest_pkg = @import("pkg/manifest.zig");
/// List of public declarations intentionally exported by the root module.
/// This array defines the public API surface that other modules can rely on.
/// It serves as documentation and can be used for validation or tooling.
pub const PublicSurface = [_][]const u8{
"main",
"libraryManifest",
"PublicSurface",
};
/// Provide a canonical manifest describing the library surface that this module exposes.
/// Other modules import this helper to reason about the package-level API.
/// Returns a Manifest struct containing metadata about the library's public interface.
pub fn libraryManifest() manifest_pkg.Manifest {
// Delegate to the manifest package to construct a sample library descriptor
return manifest_pkg.sampleLibrary();
}
/// Entry point demonstrating module role classification and vocabulary.
/// Analyzes both the root module and a library module, printing their characteristics:
/// - Whether they export a main function (indicating program vs library intent)
/// - Public symbol counts (API surface area)
/// - Role recommendations based on module structure
pub fn main() !void {
// Use a fixed-size stack buffer for stdout to avoid heap allocation
var stdout_buffer: [768]u8 = undefined;
var file_writer = std.fs.File.stdout().writer(&stdout_buffer);
const stdout = &file_writer.interface;
// Capture snapshots of module characteristics for analysis
const root_snapshot = roles.rootSnapshot();
const library_snapshot = roles.librarySnapshot();
// Retrieve role-based decision guidance
const decisions = roles.decisions();
try stdout.print("== Module vocabulary demo ==\n", .{});
// Display root module role determination based on main export
try stdout.print(
"root exports main? {s} → treat as {s}\n",
.{
if (root_snapshot.exports_main) "yes" else "no",
root_snapshot.role,
},
);
// Show the number of public declarations in the root module
try stdout.print(
"root public surface: {d} declarations\n",
.{root_snapshot.public_symbol_count},
);
// Display library module metadata: name, version, and main export status
try stdout.print(
"library '{s}' v{s} exports main? {s}\n",
.{
library_snapshot.name,
library_snapshot.version,
if (library_snapshot.exports_main) "yes" else "no",
},
);
// Show the count of public modules or symbols in the library
try stdout.print(
"library modules listed: {d}\n",
.{library_snapshot.public_symbol_count},
);
// Print architectural guidance for different module design goals
try stdout.print("intent cheat sheet:\n", .{});
for (decisions) |entry| {
try stdout.print(" - {s} → {s}\n", .{ entry.goal, entry.recommendation });
}
// Flush buffered output to ensure all content is written
try stdout.flush();
}
$ zig run module_role_map.zig== Module vocabulary demo ==
root exports main? yes → treat as program
root public surface: 3 declarations
library 'widgetlib' v0.1.0 exports main? no
library modules listed: 2
intent cheat sheet:
- ship a CLI entry point → program
- publish reusable code → package + library
- share type definitions inside a workspace → moduleKeep root exports minimal and document them in one place (PublicSurface here) so helper modules can reason about intent without relying on undocumented globals.
Under the hood: entry points and programs
Whether a module graph behaves as a program or a library depends on whether it ultimately exports an entry point symbol. std.start decides which symbol to export based on platform, link mode, and a few builtin fields, so the presence of main is only part of the story.
Entry point symbol table
| Platform | Link Mode | Conditions | Exported Symbol | Handler Function |
|---|---|---|---|---|
| POSIX/Linux | Executable | Default | _start | _start() |
| POSIX/Linux | Executable | Linking libc | main | main() |
| Windows | Executable | Default | wWinMainCRTStartup | WinStartup() / wWinMainCRTStartup() |
| Windows | Dynamic Library | Default | _DllMainCRTStartup | _DllMainCRTStartup() |
| UEFI | Executable | Default | EfiMain | EfiMain() |
| WASI | Executable (command) | Default | _start | wasi_start() |
| WASI | Executable (reactor) | Default | _initialize | wasi_start() |
| WebAssembly | Freestanding | Default | _start | wasm_freestanding_start() |
| WebAssembly | Linking libc | Default | __main_argc_argv | mainWithoutEnv() |
| OpenCL/Vulkan | Kernel | Default | main | spirvMain2() |
| MIPS | Any | Default | __start | (same as _start) |
Sources:start.zig
Compile-time entry point logic
At compile time, std.start runs a small decision tree over builtin.output_mode, builtin.os, link_libc, and the target architecture to export exactly one of the symbols above:
Sources:lib/std/start.zig:28-100
Library manifests and internal reuse
The manifest recorded in pkg/manifest.zig models what eventually becomes package metadata: a name, semantic version, a list of modules, and an explicit statement that no entry point is exported.
Packages as distribution contracts
Packages are agreements between producers and consumers: producers register module names and expose metadata; consumers import those names without touching filesystem paths, trusting the build graph to supply the right code.
Registering modules with -M and --dep
Zig 0.15.2 replaces legacy --pkg-begin/--pkg-end syntax with -M (module definition) and --dep (import table entry), mirroring what std.build does when it wires workspaces (see Build.zig).
// Import the standard library for common utilities and types
const std = @import("std");
// Import builtin module to access compile-time information about the build
const builtin = @import("builtin");
// Import the overlay module by name as it will be registered via --dep/-M on the CLI
const overlay = @import("overlay");
/// Entry point for the package overlay demonstration program.
/// Demonstrates how to use the overlay_widget library to display package information
/// including build mode and target operating system details.
pub fn main() !void {
// Allocate a fixed-size buffer on the stack for stdout operations
// This avoids heap allocation for simple output scenarios
var stdout_buffer: [512]u8 = undefined;
// Create a buffered writer for stdout to improve performance by batching writes
var file_writer = std.fs.File.stdout().writer(&stdout_buffer);
const stdout = &file_writer.interface;
// Populate package details structure with information about the current package
// This includes compile-time information like optimization mode and target OS
const details = overlay.PackageDetails{
.package_name = "overlay",
.role = "library package",
// Extract the optimization mode name (e.g., Debug, ReleaseFast) at compile time
.optimize_mode = @tagName(builtin.mode),
// Extract the target OS name (e.g., linux, windows) at compile time
.target_os = @tagName(builtin.target.os.tag),
};
// Render the package summary to stdout using the overlay library
try overlay.renderSummary(stdout, details);
// Ensure all buffered output is written to the terminal
try stdout.flush();
}
const std = @import("std");
/// Summary of a package registration as seen from the consumer invoking `--pkg-begin`.
pub const PackageDetails = struct {
package_name: []const u8,
role: []const u8,
optimize_mode: []const u8,
target_os: []const u8,
};
/// Render a formatted summary that demonstrates how package registration exposes modules by name.
pub fn renderSummary(writer: anytype, details: PackageDetails) !void {
try writer.print("registered package: {s}\n", .{details.package_name});
try writer.print("role advertised: {s}\n", .{details.role});
try writer.print("optimize mode: {s}\n", .{details.optimize_mode});
try writer.print("target os: {s}\n", .{details.target_os});
try writer.print(
"resolved module namespace: overlay → pub decls: {d}\n",
.{moduleDeclCount()},
);
}
fn moduleDeclCount() usize {
// Enumerate the declarations exported by this module to simulate API surface reporting.
return std.meta.declarations(@This()).len;
}
$ zig build-exe --dep overlay -Mroot=package_overlay_demo.zig -Moverlay=overlay_widget.zig -femit-bin=overlay_demo && ./overlay_demoregistered package: overlay
role advertised: library package
optimize mode: Debug
target os: linux
resolved module namespace: overlay → pub decls: 2--dep overlay must precede the module declaration that consumes it; otherwise the import table stays empty and the compiler cannot resolve @import("overlay").
Case study: compiler bootstrap command
The Zig compiler itself is built using the same -M/--dep machinery. During the bootstrap from zig1 to zig2, the command line wires multiple named modules and their dependencies:
zig1 <lib-dir> build-exe -ofmt=c -lc -OReleaseSmall \ --name zig2 \ -femit-bin=zig2.c \ -target <host-triple> \ --dep build_options \ --dep aro \ -Mroot=src/main.zig \ -Mbuild_options=config.zig \ -Maro=lib/compiler/aro/aro.zig
Here, each --dep line queues a dependency for the next -M module declaration, just like in the small overlay demo but at compiler scale.
From CLI flags to build graph
Once you move from ad-hoc zig build-exe commands to a build.zig file, the same concepts reappear as std.Build and std.Build.Module nodes in a build graph. The diagram below summarizes how the native build system’s entry point wires compiler compilation, tests, docs, and installation.
Documenting package intent
Beyond the CLI flags, intent lives in documentation: describe which modules are public, whether you expect downstream entry points, and how the package should be consumed by other build graphs (see Module.zig).
Choosing the right unit fast
Use the cheat sheet below when deciding what to create next; it is intentionally opinionated so teams develop shared defaults. 19
| You want to… | Prefer | Rationale |
|---|---|---|
| Publish reusable algorithms with no entry point | Package + library | Bundle modules with metadata so consumers can import by name and stay decoupled from paths. |
| Ship a command-line tool | Program | Export a main (or _start) and keep helper modules private unless you intend to share them. |
| Share types across files inside one repo | Module | Use plain @import to expose namespaces without coupling build metadata prematurely. 19 |
Artifact types at a glance
The compiler’s output_mode and link_mode choices determine the concrete artifact form that backs each conceptual role. Programs usually build as executables, while libraries use Lib outputs that can be static or dynamic.
Sources:Config.zig, main.zig, builtin.zig
You can combine the vocabulary from this chapter with these artifact types using a simple mapping:
| Role | Typical artifact | Notes |
|---|---|---|
| Program | output_mode: Exe (static or dynamic) | Exposes an entry point; may also export helper modules internally. |
| Library package | output_mode: Lib (static or shared) | Intended for reuse; no root main, consumers import modules by name. |
| Internal module | Depends on context | Often compiled as part of an executable or library; exposed via @import rather than a standalone artifact. |
Notes & Caveats
- Record manifest-like data even in ad-hoc modules so later promotion to a package is mechanical.
- When you convert a program into a library, delete or guard the entry point; otherwise consumers get conflicting roots. 19
- The
-M/--depworkflow is a thin veneer overstd.build.Module, so prefer the build graph once your project exceeds a single binary. 21
Exercises
- Extend
module_role_map.zigso the cheat sheet is driven by data loaded from a JSON manifest, then compare the ergonomics with direct Zig structs. 12, json.zig - Modify the overlay demo to register two external modules and emit their declaration counts, reinforcing how
--depqueues multiple imports. - Draft a
zig buildscript that wraps the overlay example, verifying that the CLI flags map cleanly tob.addModuleandmodule.addImport. 21
Caveats, alternatives, edge cases
- Cross-compiling packages may expose
target-specific modules; document conditional imports to prevent surprise name resolution failures. - If you register a module name twice in the same build graph, the zig CLI reports a collision—treat that as a signal to refactor rather than relying on ordering. 19
- Some tooling still expects the deprecated
--pkg-beginsyntax; upgrade scripts in tandem with the compiler to keep dependency registration consistent. v0.15.2