Structs, Enums & Unions
struct bundles fields, enum names a closed set of tags, and union(enum) lets alternative shapes share one type. Paired with exhaustive switch, the compiler turns forgotten cases into errors.
struct — Records
A struct is a sequence of named, typed fields. Initialization uses named field syntax, which is self-documenting and survives field reordering without silent bugs (the compiler rejects mismatched init order).
Declaring and Initializing
const Point = struct {
x: f64,
y: f64,
};
const origin = Point{ .x = 0.0, .y = 0.0 };
const moved = Point{ .x = 3.0, .y = 4.0 };
std.debug.print("{d}\n", .{moved.x});
Methods — Namespaced Functions
There is no class inheritance; a struct is a namespace. Functions declared inside it become associated methods taking the struct explicitly — Zig keeps the OO convenience without hiding the receiver.
const Rect = struct {
w: u32,
h: u32,
fn area(self: *const Rect) u32 {
return self.w * self.h;
}
};
const box = Rect{ .w = 4, .h = 3 };
std.debug.print("{d}\n", .{box.area()}); // 12
enum — Tagged Values
An enum is a closed set of named values, each with an integer backing tag. It replaces the error-prone C pattern of #define constants and manual validation.
Declaring and Converting
const Color = enum { red, green, blue };
const c: Color = .green; // enum literal shorthand
const backing: u8 = @intFromEnum(c); // 1 — the tag value
const back: Color = @enumFromInt(1); // .green — checked at runtime
Exhaustive switch
Switching an enum without covering every variant is a compile error. Adding a variant to the enum updates every switch — forgotten sites fail to build, which is exactly the safety nettle C never solved.
fn hexName(c: Color) []const u8 {
return switch (c) {
.red => "#ff0000",
.green => "#00ff00",
.blue => "#0000ff",
// no else needed: red, green, blue are all covered
};
}
union — Alternatives
A plain union stores one of several types, but reading it safely needs a tag. Zig models this precisely with a tagged union, which pairs the payload with its discriminator.
Tagged Unions
const Value = union(enum) {
int: i64,
float: f64,
text: []const u8,
};
const v1 = Value{ .int = 42 };
const v2 = Value{ .text = "sage" };
switch with Payloads
Extracting the payload is done by an exhaustive switch that captures the inner value — the tag check and the cast happen in one visible step.
fn describe(v: Value) void {
switch (v) {
.int => |n| std.debug.print("integer {d}\n", .{n}),
.float => |f| std.debug.print("float {d}\n", .{f}),
.text => |s| std.debug.print("text {s}\n", .{s}),
}
}
extern, packed and opaque
Three modifiers adapt structs to the raw world: extern struct follows the C ABI for interfacing, packed struct squeezes bitfields, and opaque hides layout entirely for foreign handles.
extern Structs for FFI
const Timespec = extern struct {
tv_sec: i64,
tv_nsec: i64,
}; // layout matches the C struct from
packed Structs
Bit-level control belongs to protocols and hardware: packed struct orders bits without padding. Prefer it only where the byte layout is a contract, and test against the spec.
Common Pitfalls
Field Order and Layout
Non-extern structs may be reordered by the compiler; rely on declared order only for logical grouping, not for byte layout, unless you chose extern or packed.
else That Hides Cases
An else => prong on a tagged-union switch swallows future variants silently. When new cases should fail loudly, omit else and let the compiler demand coverage.
Next: Build System — build.zig and the zig build workflow.