Chapter 08User Types Structs Enums Unions

User Types

Overview

Zig’s user-defined types are deliberately small, sharp tools. Structs compose data and behavior under a clean namespace, enums encode closed sets of states with explicit integer representations, and unions model variant data—tagged for safety or untagged for low-level control. Together, these form the backbone of ergonomic APIs and memory-aware systems code; see #Structs, #Enums, and #Unions for reference.

This chapter builds pragmatic fluency: methods and defaults on structs, enum round-trips with @intFromEnum/@enumFromInt, and both tagged and untagged unions. We’ll also peek at layout modifiers (packed, extern) and anonymous structs/tuples, which become handy for lightweight return values and FFI. See fmt.zig and math.zig for related helpers.

Learning Goals

  • Define and use structs with methods, defaults, and clear namespacing.
  • Convert enums to and from integers safely and match on them exhaustively.
  • Choose between tagged and untagged unions; understand when packed/extern layout matters (see #packed struct and #extern struct).

Structs: Data + Namespace

Structs gather fields and related helper functions. Methods are just functions with an explicit receiver parameter—no magic, which keeps call sites obvious and unit-testable. Defaults reduce boilerplate for common cases.

Zig
const std = @import("std");

// Chapter 8 — Struct basics: fields, methods, defaults, namespacing
// 
// Demonstrates defining a struct with fields and methods, including
// default field values. Also shows namespacing of methods vs free functions.
//
// Usage: 
//    zig run struct_basics.zig

const Point = struct {
    x: i32,
    y: i32 = 0, // default value

    pub fn len(self: Point) f64 {
        const dx = @as(f64, @floatFromInt(self.x));
        const dy = @as(f64, @floatFromInt(self.y));
        return std.math.sqrt(dx * dx + dy * dy);
    }

    pub fn translate(self: *Point, dx: i32, dy: i32) void {
        self.x += dx;
        self.y += dy;
    }
};

// Namespacing: free function in file scope vs method
fn distanceFromOrigin(p: Point) f64 {
    return p.len();
}

pub fn main() !void {
    var p = Point{ .x = 3 }; // y uses default 0
    std.debug.print("p=({d},{d}) len={d:.3}\n", .{ p.x, p.y, p.len() });

    p.translate(-3, 4);
    std.debug.print("p=({d},{d}) len={d:.3}\n", .{ p.x, p.y, distanceFromOrigin(p) });
}
Run
Shell
$ zig run struct_basics.zig
Output
Shell
p=(3,0) len=3.000
p=(0,4) len=4.000

Methods are namespaced functions; you can freely mix free functions and methods depending on testability and API clarity.

Enums: States with Bit-Exact Reprs

Enums can set their integer representation (e.g., enum(u8)) and convert to/from integers with builtins. A switch over an enum must be exhaustive unless you include else, which is perfect for catching new states at compile time.

Zig
const std = @import("std");

// Chapter 8 — Enums: integer repr, conversions, exhaustiveness
//
// Demonstrates defining an enum with explicit integer representation,
// converting between enum and integer using @intFromEnum and @enumFromInt,
// and pattern matching with exhaustiveness checking.
//
// Usage: 
//    zig run enum_roundtrip.zig

const Mode = enum(u8) {
    Idle = 0,
    Busy = 1,
    Paused = 2,
};

fn describe(m: Mode) []const u8 {
    return switch (m) {
        .Idle => "idle",
        .Busy => "busy",
        .Paused => "paused",
    };
}

pub fn main() !void {
    const m: Mode = .Busy;
    const int_val: u8 = @intFromEnum(m);
    std.debug.print("m={s} int={d}\n", .{ describe(m), int_val });

    // Round-trip using @enumFromInt; the integer must map to a declared tag.
    const m2: Mode = @enumFromInt(2);
    std.debug.print("m2={s} int={d}\n", .{ describe(m2), @intFromEnum(m2) });
}
Run
Shell
$ zig run enum_roundtrip.zig
Output
Shell
m=busy int=1
m2=paused int=2

@enumFromInt requires that the integer maps to a declared tag. If you expect unknown values (e.g., file formats), consider a sentinel tag, validation paths, or separate integer parsing with explicit error handling.

Unions: Variant Data

A tagged union carries both a tag and a payload; pattern matching is straightforward and type-safe. Untagged unions require you to manage the active field manually and are appropriate for low-level bit reinterpretations or FFI shims.

Zig
const std = @import("std");

// Chapter 8 — Unions: tagged and untagged
//
// Demonstrates a tagged union (with enum discriminant) and an untagged union
// (without discriminant). Tagged unions are safe and idiomatic; untagged
// unions are advanced and unsafe if used incorrectly.
//
// Usage: 
//    zig run union_demo.zig

const Kind = enum { number, text };

const Value = union(Kind) {
    number: i64,
    text: []const u8,
};

// Untagged union (advanced): requires external tracking and is unsafe if used wrong.
const Raw = union { u: u32, i: i32 };

pub fn main() !void {
    var v: Value = .{ .number = 42 };
    printValue("start: ", v);

    v = .{ .text = "hi" };
    printValue("update: ", v);

    // Untagged example: write as u32, read as i32 (bit reinterpret).
    const r = Raw{ .u = 0xFFFF_FFFE }; // -2 as signed 32-bit
    const as_i: i32 = @bitCast(r.u);
    std.debug.print("raw u=0x{X:0>8} i={d}\n", .{ r.u, as_i });
}

fn printValue(prefix: []const u8, v: Value) void {
    switch (v) {
        .number => |n| std.debug.print("{s}number={d}\n", .{ prefix, n }),
        .text => |s| std.debug.print("{s}{s}\n", .{ prefix, s }),
    }
}
Run
Shell
$ zig run union_demo.zig
Output
Shell
start: number=42
update: hi
raw u=0xFFFFFFFE i=-2

Reading a different field from an untagged union without reinterpreting the bits (e.g., via @bitCast) is illegal; Zig prevents this at compile time. Prefer tagged unions for safety unless you truly need the control.

Tagged Union Memory Representation

Understanding how tagged unions are laid out in memory clarifies the safety vs space trade-off and explains when to choose tagged vs untagged unions:

graph TB subgraph "Tagged Union Definition" TAGGED["const Value = union(enum) {<br/> number: i32, // 4 bytes<br/> text: []const u8, // 16 bytes (ptr+len)<br/>}"] end subgraph "Tagged Union Memory (24 bytes on 64-bit)" TAG_MEM["Memory Layout:<br/><br/>| tag (u8) | padding | payload (16 bytes) |<br/><br/>Tag identifies active field<br/>Payload holds largest variant"] end subgraph "Untagged Union Definition" UNTAGGED["const Raw = union {<br/> number: i32,<br/> text: []const u8,<br/>}"] end subgraph "Untagged Union Memory (16 bytes)" UNTAG_MEM["Memory Layout:<br/><br/>| payload (16 bytes) |<br/><br/>No tag - you track active field<br/>Size = largest variant only"] end TAGGED --> TAG_MEM UNTAGGED --> UNTAG_MEM subgraph "Access Patterns" SAFE["Tagged: Safe Pattern Matching<br/>switch (value) {<br/> .number => |n| use(n),<br/> .text => |t| use(t),<br/>}"] UNSAFE["Untagged: Manual Tracking<br/>// You must know which field is active<br/>const n = raw.number; // Unsafe!"] end TAG_MEM --> SAFE UNTAG_MEM --> UNSAFE

Memory layout details:

Tagged Union: - Size = tag size + padding + largest variant size - Tag field (typically u8 or smallest integer that fits tag count) - Padding for alignment of payload - Payload space sized to hold the largest variant - Example: union(enum) { i32, []const u8 } = 1 byte tag + 7 bytes padding + 16 bytes payload = 24 bytes

Untagged Union: - Size = largest variant size (no tag overhead) - No runtime tag to check - You’re responsible for tracking which field is active - Example: union { i32, []const u8 } = 16 bytes (just the payload)

When to use each:

  • Use Tagged Unions (default choice):
  • Use Untagged Unions (rare, expert use):

Safety guarantees:

Tagged unions provide compile-time exhaustiveness checking and runtime tag validation:

Zig
const val = Value{ .number = 42 };
switch (val) {
    .number => |n| print("{}", .{n}),  // OK - matches tag
    .text => |t| print("{s}", .{t}),  // Compiler ensures both cases covered
}

Untagged unions require you to maintain safety invariants manually—the compiler can’t help you.

Layout and Anonymous Structs/Tuples

When you must fit bits precisely (wire formats) or match C ABI layout, Zig offers packed and extern. Anonymous structs (often called "tuples") are convenient for quick multi-value returns.

Zig
const std = @import("std");

// Chapter 8 — Layout (packed/extern) and anonymous structs/tuples

const Packed = packed struct {
    a: u3,
    b: u5,
};

const Extern = extern struct {
    a: u32,
    b: u8,
};

pub fn main() !void {
    // Packed bit-fields combine into a single byte.
    std.debug.print("packed.size={d}\n", .{@sizeOf(Packed)});

    // Extern layout matches the C ABI (padding may be inserted).
    std.debug.print("extern.size={d} align={d}\n", .{ @sizeOf(Extern), @alignOf(Extern) });

    // Anonymous struct (tuple) literals and destructuring.
    const pair = .{ "x", 42 };
    const name = @field(pair, "0");
    const value = @field(pair, "1");
    std.debug.print("pair[0]={s} pair[1]={d} via names: {s}/{d}\n", .{ @field(pair, "0"), @field(pair, "1"), name, value });
}
Run
Shell
$ zig run layout_and_anonymous.zig
Output
Shell
packed.size=1
extern.size=8 align=4
pair[0]=x pair[1]=42 via names: x/42

Tuple field access uses @field(val, "0") and @field(val, "1"). They’re anonymous structs with numeric field names, which keeps them simple and allocation-free.

Memory Layout: Default vs Packed vs Extern

Zig offers three struct layout strategies, each with different trade-offs for memory efficiency, performance, and compatibility:

graph TB subgraph "Default Layout (Optimized)" DEF_CODE["const Point = struct {<br/> x: u8, // 1 byte<br/> y: u32, // 4 bytes<br/> z: u8, // 1 byte<br/>};"] DEF_MEM["Memory: 12 bytes<br/><br/>| x | pad(3) | y(4) | z | pad(3) |<br/><br/>Compiler reorders & pads for efficiency"] end subgraph "Packed Layout (No Padding)" PACK_CODE["const Flags = packed struct {<br/> a: bool, // 1 bit<br/> b: u3, // 3 bits<br/> c: bool, // 1 bit<br/> d: u3, // 3 bits<br/>};"] PACK_MEM["Memory: 1 byte<br/><br/>| abcd(8 bits) |<br/><br/>No padding, bit-exact packing"] end subgraph "Extern Layout (C ABI)" EXT_CODE["const Data = extern struct {<br/> x: u8,<br/> y: u32,<br/> z: u8,<br/>};"] EXT_MEM["Memory: 12 bytes<br/><br/>| x | pad(3) | y(4) | z | pad(3) |<br/><br/>C ABI rules, field order preserved"] end DEF_CODE --> DEF_MEM PACK_CODE --> PACK_MEM EXT_CODE --> EXT_MEM subgraph "Key Differences" DIFF1["Default: Compiler can reorder fields<br/>Extern: Field order fixed<br/>Packed: Bit-level packing"] DIFF2["Default: Optimized alignment<br/>Extern: Platform ABI alignment<br/>Packed: No alignment (bitfields)"] end

Layout mode comparison:

LayoutSize/AlignmentField OrderUse Case
DefaultOptimized by compilerCan be reorderedNormal Zig code
PackedBit-exact, no paddingFixed, bit-levelWire formats, bit flags
ExternC ABI rulesFixed (declaration order)FFI, C interop

Detailed behavior:

Default Layout:

Zig
const Point = struct {
    x: u8,   // Compiler might reorder this
    y: u32,  // to minimize padding
    z: u8,
};
// Compiler chooses optimal order, typically:
// y (4 bytes, aligned) + x (1 byte) + z (1 byte) + padding

Packed Layout:

Zig
const Flags = packed struct {
    enabled: bool,    // bit 0
    mode: u3,         // bits 1-3
    priority: u4,     // bits 4-7
};
// Total: 8 bits = 1 byte, no padding
// Perfect for hardware registers and wire protocols

Extern Layout:

Zig
const CHeader = extern struct {
    version: u32,  // Matches C struct layout exactly
    flags: u16,    // Field order preserved
    padding: u16,  // Explicit padding if needed
};
// For calling C functions or reading C-written binary data

When to use each layout:

  • Default (no modifier):
  • Packed:
  • Extern:

Important notes:

  • Use @sizeOf(T) and @alignOf(T) to verify layout
  • Packed structs can be slower—measure before optimizing
  • Extern structs must match the C definition exactly (including padding)
  • Default layout may change between compiler versions (always safe, but field order not guaranteed)

Notes & Caveats

  • Methods are sugar-free; consider making helpers pub inside the struct for discoverability and test scoping.
  • Enum reprs (enum(uN)) define size and affect ABI/FFI—choose the smallest that fits your protocol.
  • Untagged unions are sharp tools. In most application code, prefer tagged unions and pattern matching.

Exercises

  • Add a scale method to Point that multiplies both coordinates by a f64, then reworks len to avoid precision loss for large integers.
  • Extend Mode with a new Error state and observe how the compiler enforces an updated switch.
  • Create a tagged union representing a JSON scalar (null, bool, number, string) and write a print function that formats each case.

Alternatives & Edge Cases

  • ABI layout: extern respects the platform ABI. Verify sizes with @sizeOf/@alignOf and cross-compile when shipping libraries.
  • Bit packing: packed struct compresses fields but can increase instruction count; measure before committing in hot paths.
  • Tuples vs named structs: prefer named structs for stable APIs; tuples shine for local, short-lived glue.

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.