Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

7b: Structs, Unions, and Value Types

Audience: All
Time: 120 minutes
Prerequisites: 02-Values, 04-Functions, 07-Classes
You'll learn: Structs (value types), unions (tagged unions), ^T heap indirection, except for struct updates, branch pattern matching on unions


The Big Picture

Zebra has three kinds of composite types:

TypeSemanticsAssignment copies...Allocated on...
classReferenceThe pointerHeap
structValueThe whole valueStack
unionTagged unionThe whole valueStack

You already know classes (Chapter 07). This chapter covers the other two — and they're essential for writing efficient, safe Zebra code.


Structs: Value Types

A struct is like a class, but with value semantics. When you assign a struct variable, the entire value is copied.

# file: 07b_struct_basic.zbr
# teaches: struct definition and value semantics
# chapter: 07b-Structs-Unions

struct Point
    var x: int
    var y: int

    cue init(x: int, y: int)
        this.x = x
        this.y = y

    def distSq(): int
        return x * x + y * y

def main()
    var a = Point(3, 4)
    var b = a              # copies the entire Point
    print(a.distSq())  # 25
    print(b.x)  # 3

When to use struct vs class

  • Struct: Small, short-lived values. Config objects, coordinates, tokens, AST nodes.
  • Class: Long-lived objects with identity. Collections, services, shared mutable state.

Rule of thumb: if you'd never ask "is this the same object?", use a struct.

Cues on structs

A struct takes the same cues as a class (Chapter 07, "Cues"): toString for printing, equals for ==, hash for use as a key, compare for < and sort(), iter / next for for. They work by value. There is no identity to fall back on, so == on a struct needs an equals, written by hand or by @derive(Eq).

# file: 07b_struct_cues.zbr
# teaches: cues on a struct -- toString, equals, hash, compare, by value
# chapter: 07b-Structs-Unions

struct Version
    var major: int
    var minor: int

    cue toString(): str
        return "v${.major}.${.minor}"

    cue equals(other: Version): bool
        return .major == other.major and .minor == other.minor

    cue hash(): int
        return .major * 1000 + .minor

    cue compare(other: Version): int
        if .major != other.major
            return .major - other.major
        return .minor - other.minor

def main()
    var installed = Version(major: 1, minor: 10)
    var wanted = Version(major: 1, minor: 2)
    print(installed)
    print(installed > wanted)          # compare: 10 > 2, not "1.10" < "1.2"

    var releases = List(Version)()
    releases.add(installed)
    releases.add(wanted)
    releases.add(Version(major: 0, minor: 9))
    releases.sort()
    for r in releases
        print(r)

    var copy = installed               # a struct copy...
    print(copy == installed)           # ...is equal by value
    var seen: Set(Version) = Set()
    seen.add(installed)
    seen.add(copy)
    print(seen.count())

Output:

v1.10
true
v0.9
v1.2
v1.10
true
1

@derive(Debug, Eq, Hash) (Chapter 07) writes toString, equals and hash field by field. It does not write compare; write that one yourself, as Version does. compare needs an equals on the same type, and a derived one counts, so @derive(Eq) plus a hand-written cue compare is enough. A hand-written cue wins over a derived one, so you can derive Eq, Hash and still write your own toString.


except — Struct Update Copies

The except keyword creates a copy of a struct with specific fields overridden. This is the primary idiom for immutable-style struct manipulation:

# file: 07b_except.zbr
# teaches: except struct update syntax
# chapter: 07b-Structs-Unions

struct Config
    var indent: int
    var owner: str
    var verbose: bool

    cue init(indent: int, owner: str, verbose: bool)
        this.indent = indent
        this.owner = owner
        this.verbose = verbose

    def indented(): Config
        var c = this except
            indent = indent + 1
        return c

    def withOwner(name: str): Config
        var c = this except
            owner = name
        return c

def main()
    var base = Config(0, "nobody", false)
    var inner = base.indented()
    var owned = inner.withOwner("Alice")
    print(owned.indent)  # 1
    print(owned.owner)  # Alice
    print(owned.verbose)  # false (unchanged)

Critical rule: no method chaining on temporaries

# WRONG — temporary struct can't be mutated:
# var c = makeConfig().indented().withOwner("Foo")

# RIGHT — use intermediate variables:
var c0 = makeConfig()
var c1 = c0.indented()
var c = c1.withOwner("Foo")

Enums

Enums define a fixed set of named constants:

# file: 07b_enum.zbr
# teaches: enum types
# chapter: 07b-Structs-Unions

enum Color
    red
    green
    blue

enum Status(int)
    ok = 0
    err = 1

def main()
    var c = Color.red
    var s = Status.ok
    print(s)  # 0

Use branch (see below) to match on enum values.


Unions: Tagged Unions

A union is a type that can hold one of several variants, each with its own payload type. Think of it as a type-safe alternative to "this could be an int OR a string OR nothing."

# file: 07b_union_basic.zbr
# teaches: union definition and construction
# chapter: 07b-Structs-Unions

union Value
    int_: int
    str_: str
    bool_: bool
    none_

def main()
    var v1 = Value.int_(42)
    var v2 = Value.str_("hello")
    var v3 = Value.none_()

Naming convention: Variants that collide with Zebra keywords get a trailing underscore: int_, str_, bool_, none_.


branch — Pattern Matching on Unions

Use branch to inspect which variant a union holds:

# file: 07b_branch.zbr
# teaches: branch pattern matching
# chapter: 07b-Structs-Unions

union Shape
    circle: float           # radius
    rect: Dims              # width/height struct
    point                   # no payload

struct Dims
    var w: float
    var h: float
    cue init(w: float, h: float)
        this.w = w
        this.h = h

def describe(s: Shape): str
    branch s
        on Shape.circle as r
            return "Circle with radius ${r}"
        on Shape.rect as d
            return "Rectangle ${d.w} x ${d.h}"
        on Shape.point
            return "Point"
        else
            pass
    return "unknown"

def main()
    var c = Shape.circle(3.14)
    var r = Shape.rect(Dims(10.0, 5.0))
    print(describe(c))  # Circle with radius 3.14
    print(describe(r))  # Rectangle 10.0 x 5.0

Rules:

  • Each on arm binds the variant's payload to a name (as r, as d)
  • else with pass is required for non-exhaustive branches
  • Payload-less variants (point) don't need as binding

Branch on enums and strings

branch also works on enums and strings:

branch color
    on Color.red
        print("Stop")
    on Color.green
        print("Go")
    else
        print("Unknown")

branch command
    on "quit"
        sys.exit(0)
    on "help"
        print("Available commands: quit, help")
    else
        print("Unknown command")

^T — Heap Indirection for Recursive Types

Structs and unions live on the stack. But what if a type needs to reference itself? A Node with a next field that's also a Node would be infinitely sized.

^T solves this — it's a heap-allocated pointer to T:

# file: 07b_heap_indirect.zbr
# teaches: ^T heap indirection
# chapter: 07b-Structs-Unions

struct Node
    var value: int
    var next: ^Node?     # optional heap pointer to another Node

    cue init(value: int, next: ^Node?)
        this.value = value
        this.next = next

def main()
    var a = Node(1, nil)
    var b = Node(2, nil)
    a.next = b              # auto-boxes: copies b to the heap
    print(a.value)  # 1

Key points:

  • ^T in field type → *T in Zig (a pointer)
  • ^T? → ?*T (an optional pointer)
  • Assignment to a ^T field auto-boxes: allocates a heap copy
  • Inside branch, ^T payloads are transparent — the binding has type T, not *T

Where you see ^T in practice

The Zebra compiler's own AST uses ^T heavily:

union Expr
    int_: int
    str_: str
    binary: ^ExprBinary       # recursive: contains two sub-Exprs
    call: ^ExprCall

struct ExprBinary
    var left: ^Expr
    var op: BinaryOp
    var right: ^Expr

Putting It All Together: A Small AST

# file: 07b_mini_ast.zbr
# teaches: combining structs, unions, and ^T
# chapter: 07b-Structs-Unions

union Expr
    num: int
    add: ^BinExpr
    neg: ^Expr

struct BinExpr
    var left: ^Expr
    var right: ^Expr

    cue init(left: ^Expr, right: ^Expr)
        this.left = left
        this.right = right

def eval(e: Expr): int
    branch e
        on Expr.num as n
            return n
        on Expr.add as b
            return eval(b.left) + eval(b.right)
        on Expr.neg as inner
            return 0 - eval(inner)
        else
            pass
    return 0

def main()
    # 3 + (neg 2) = 1
    var three = Expr.num(3)
    var two = Expr.neg(Expr.num(2))
    var sum = Expr.add(BinExpr(three, two))
    print(eval(sum))  # 1

Exercises

Exercise 1: RGB Color Struct

Define a Color struct with r, g, b fields (all int). Add a mix method that averages two colors using except.

Solution
struct Color
    var r: int
    var g: int
    var b: int

    cue init(r: int, g: int, b: int)
        this.r = r
        this.g = g
        this.b = b

    def mix(other: Color): Color
        var c = this except
            r = (r + other.r) / 2
            g = (g + other.g) / 2
            b = (b + other.b) / 2
        return c

def main()
    var red = Color(255, 0, 0)
    var blue = Color(0, 0, 255)
    var purple = red.mix(blue)
    print(purple.r)  # 127
    print(purple.b)  # 127

Exercise 2: Expression Evaluator

Extend the mini AST to support multiplication. Add a mul variant and handle it in eval.


Key Takeaways

  • struct = value type; assignment copies the whole value
  • Cues work on structs by value — == on a struct needs equals (hand-written or @derive(Eq)); compare is always hand-written and needs an equals beside it
  • union = tagged union; holds one variant at a time
  • branch = pattern matching on unions, enums, and strings
  • except = create a struct copy with specific fields overridden
  • ^T = heap-indirection pointer; breaks recursive type cycles
  • Use structs for small config/data objects, classes for stateful services

Next: Head to 08-Interfaces for polymorphism without inheritance.