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/externlayout 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.
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) });
}
$ zig run struct_basics.zigp=(3,0) len=3.000
p=(0,4) len=4.000Methods 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.
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) });
}
$ zig run enum_roundtrip.zigm=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.
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 }),
}
}
$ zig run union_demo.zigstart: number=42
update: hi
raw u=0xFFFFFFFE i=-2Reading 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:
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:
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.
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 });
}
$ zig run layout_and_anonymous.zigpacked.size=1
extern.size=8 align=4
pair[0]=x pair[1]=42 via names: x/42Tuple 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:
Layout mode comparison:
| Layout | Size/Alignment | Field Order | Use Case |
|---|---|---|---|
| Default | Optimized by compiler | Can be reordered | Normal Zig code |
| Packed | Bit-exact, no padding | Fixed, bit-level | Wire formats, bit flags |
| Extern | C ABI rules | Fixed (declaration order) | FFI, C interop |
Detailed behavior:
Default Layout:
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) + paddingPacked Layout:
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 protocolsExtern Layout:
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 dataWhen 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
pubinside 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
scalemethod toPointthat multiplies both coordinates by af64, then reworkslento avoid precision loss for large integers. - Extend
Modewith a newErrorstate and observe how the compiler enforces an updatedswitch. - Create a tagged union representing a JSON scalar (
null,bool,number,string) and write aprintfunction that formats each case.
Alternatives & Edge Cases
- ABI layout:
externrespects the platform ABI. Verify sizes with@sizeOf/@alignOfand cross-compile when shipping libraries. - Bit packing:
packed structcompresses 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.