Structs, Enums & Unions

Zig offers three composable tools for data: 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.

Thinking in Zig: model the data, then let the compiler defend it. An enum that cannot hold an unknown value, a tagged union that cannot misrepresent its payload, a switch that cannot miss a case — every one of these is a bug class removed by the type system, not by discipline.

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.