Why Zig fits this course
Most languages you’ve used hide the machine. JavaScript has a garbage collector, stores every number as a 64-bit float, and has no pointers. That’s great for productivity, but it means you never see the things from Part I. Zig is a systems language designed to keep the machine visible, without C’s traps. Its design maps straight onto what you’ve just learned:
- Integers have explicit sizes (
u8,i32), and overflow is caught instead of silently wrapping. - Memory allocation is always explicit; nothing allocates behind your back.
- You can control struct layout exactly when you need to.
- Safety checks (bounds, overflow, null) are on while you develop and can be switched off for speed.
Setup
Install Zig from ziglang.org/download or your package manager, then check it works with zig version. The commands you’ll use most:
| Command | What it does
| zig run file.zig | compile and run a single file
| zig test file.zig | compile and run the test blocks in a file
| zig init | create a new project with a build.zig
| zig build run | build and run the project
| zig fmt file.zig | format the code
Zig is still pre-1.0, and the standard library changes between releases. These lessons stick to the core language as much as possible; if a std function has moved in your version, check the release notes.
Your first program, line by line
const std = @import("std");
pub fn main() void {
const lang = "Zig";
std.debug.print("Hello, {s}!\n", .{lang});
}
@import("std")loads the standard library. Anything starting with@is a builtin: a function provided by the compiler itself.pub fn main() voidis where the program starts.pubmakes it visible outside this file, andvoidmeans it returns nothing.std.debug.printprints to stderr. The first argument is a format string:{s}means “a string goes here”,{d}a number in decimal,{x}a number in hex..{lang}is an anonymous tuple holding the values to fill in. Even a single value goes inside.{ }. Save it ashello.zigand runzig run hello.zig.
const, var and undefined
const std = @import("std");
pub fn main() void {
const a: u32 = 5; // can't be reassigned
var b: u32 = undefined; // no value yet
b = a * 2;
std.debug.print("{d}\n", .{b});
}
constmeans the name can’t be reassigned;varmeans it can. Zig nudges you towardconst: declaring avarthat you never change is a compile error.- The type comes after the name:
a: u32. You can often leave it out and let Zig infer it. undefinedmeans “I’ll give this a value later”. Reading it before then is a bug. In Debug builds Zig fills undefined memory with the byte0xaa, so if you ever see0xaaaaaaaain output, you know where it came from.- An unused local variable is a compile error, which catches typos and leftovers. If you really mean to ignore a value, say so with
_ = x;.
Functions and structs
fn add(a: i32, b: i32) i32 {
return a + b;
}
const Point = struct {
x: f32,
y: f32,
fn length(self: Point) f32 {
return @sqrt(self.x * self.x + self.y * self.y);
}
};
const p = Point{ .x = 3, .y = 4 };
const len = p.length(); // 5
Parameter and return types are always written out. A struct groups fields together, and functions declared inside it can be called with dot syntax: p.length() is just shorthand for Point.length(p). There are no classes and no inheritance.
In Zig, types are values that exist at compile time. Point above is a constant whose value is a type. You can pass types to functions and return them, which is how generics work (that’s the comptime lesson). @TypeOf(x) gives you the type of any expression.
No hidden control flow
A core Zig rule: if code doesn’t look like a function call, it doesn’t call a function. There’s no operator overloading (a + b is always plain arithmetic), no exceptions jumping out of the middle of a function, no hidden memory allocation, and no getters or setters. When you read Zig, what you see is what runs.
Conversions follow the same rule. Zig only converts between types automatically when nothing can be lost: a u8 goes into a u16 fine, because every u8 value fits. A u16 into a u8 needs an explicit @intCast, because it might not fit.
Control flow is made of expressions
if, switch and blocks can produce values, which saves a lot of temporary variables:
const grade: u8 = 87;
const letter = switch (grade) {
90...100 => 'A',
80...89 => 'B',
else => 'C',
};
const total = blk: {
var sum: u32 = 0;
for ([_]u32{ 1, 2, 3 }) |n| sum += n;
break :blk sum;
};
switchmust handle every possible value, withelsecovering the rest. Forget a case and it’s a compile error, not a surprise at runtime.90...100is an inclusive range inside a switch.- A labeled block (
blk:) computes a value and hands it out withbreak :blk value. forloops over arrays and slices;|n|captures each element in turn.
Build modes: safety vs speed
Every Zig build uses one of four modes, chosen with -O:
| Mode | Safety checks | Optimized
| Debug (default) | yes | no
| ReleaseSafe | yes | yes
| ReleaseFast | no | for speed
| ReleaseSmall | no | for size
Safety checks are runtime checks for integer overflow, out-of-bounds indexing, unwrapping a null optional, and reaching unreachable. In Debug and ReleaseSafe, any of these stops the program with a message and a stack trace pointing to the exact line.
In ReleaseFast and ReleaseSmall those checks are removed. If one of those situations happens anyway, it’s illegal behaviour: the optimizer was allowed to assume it couldn’t happen, so the program may do anything at all. Develop in Debug, and ship in ReleaseSafe unless you’ve measured that you need the extra speed.
Recap
- Zig keeps the machine visible: sized integers, explicit allocation, controllable layout, safety checks.
- Prefer
const.undefinedmeans “no value yet”. Unused locals are errors. - Types are compile-time values; structs hold fields and functions.
- No hidden control flow, and only lossless automatic conversions.
- Debug and ReleaseSafe keep safety checks; ReleaseFast and ReleaseSmall drop them.