Skip to content

Organizing a Small Program

A program grows gradually.

A program grows gradually.

At first, everything may fit in one file:

const std = @import("std");

fn square(x: i32) i32 {
    return x * x;
}

pub fn main() void {
    std.debug.print("{d}\n", .{square(5)});
}

As the program becomes larger, declarations should be separated into logical units.

A common organization is:

src/
    main.zig
    math.zig
    parser.zig
    config.zig

Each file handles one area of responsibility.

For example:

// math.zig

pub fn add(a: i32, b: i32) i32 {
    return a + b;
}

pub fn sub(a: i32, b: i32) i32 {
    return a - b;
}

Another file handles configuration:

// config.zig

pub const app_name = "demo";
pub const version = "0.1.0";

The main program imports both:

const std = @import("std");
const math = @import("math.zig");
const config = @import("config.zig");

pub fn main() void {
    const result = math.add(10, 20);

    std.debug.print(
        "{s} {s}: {d}\n",
        .{
            config.app_name,
            config.version,
            result,
        },
    );
}

The output is:

demo 0.1.0: 30

Good organization reduces complexity.

Each file should answer a clear question:

  • What declarations belong together?
  • What should remain private?
  • What interface should other files use?

A file should usually expose a small public interface and hide implementation details.

For example:

fn tokenize() void {}
fn parseTokens() void {}

pub fn parse() void {
    tokenize();
    parseTokens();
}

Only parse is public.

The internal functions may change freely without affecting other files.

Programs are easier to maintain when dependencies are simple.

This structure is preferable:

main -> parser
main -> math
parser -> tokenizer

to tangled dependency graphs where every file imports every other file.

Circular dependencies are especially troublesome:

a imports b
b imports a

This often means responsibilities are poorly separated.

Shared declarations should instead move into a separate module.

Naming also matters.

File names should describe their contents:

lexer.zig
http.zig
json.zig
config.zig

Names like:

misc.zig
stuff.zig
helpers.zig

usually indicate unclear structure.

Small functions also improve organization.

This is difficult to understand:

fn process() void {
    // 300 lines
}

Breaking work into smaller functions makes programs easier to read, test, and reuse.

A useful guideline is:

  • one file for one major concept
  • one function for one operation

The main function should often remain small:

pub fn main() !void {
    try run();
}

Most real work moves into helper functions or modules.

This keeps the program entry point easy to understand.

As programs grow, consistency becomes important:

  • similar names
  • similar file layout
  • predictable interfaces
  • limited public APIs

Good structure does not eliminate complexity, but it prevents complexity from spreading everywhere.

Zig encourages explicit structure. Files, imports, and declarations are all visible in source code. There is little hidden machinery.

Exercise 4-21. Split a program into three modules: input, processing, and output.

Exercise 4-22. Move private helper functions out of the public interface.

Exercise 4-23. Refactor a large function into smaller functions.

Exercise 4-24. Why are circular imports usually a sign of poor organization?