Tagged Unions

Declaration

tape
tagged Shape {
    Circle { radius: f64; }
    Rect { width: f64; height: f64; }
    Point;
}

A tagged union stores exactly one variant at a time, with a hidden discriminant tag. Variants with no fields use ; instead of {}.

Construction

tape
let s = Shape.Circle { radius: 5.0; };
let r = Shape.Rect { width: 3.0; height: 4.0; };

A construction names every payload field, like a struct literal: a payload field can declare a default (Key { code: i64; mods: i64 = 0; }), a last .. zeroes the rest (Shape.Rect { width: 3.0; .. }), and leaving out any other field is a compile error. Each value must be of its field’s type, as in a struct literal: Shape.Circle { radius: "big"; } is a compile error (field 'radius' of Shape.Circle is f64, given string), so is radius: 5 (write 5.0), and so is a field the variant doesn’t have.

A variant without fields is a value as it is: let p = Shape.Point;. A bare Shape.Circle, which has fields, is a compile error that names them ('Shape.Circle' has fields (radius): ...), and so is a variant the union does not have (tagged union 'Shape' has no variant 'Square').

Pattern matching

tape
fn area(s: Shape) -> f64 {
    match (s) {
        Shape.Circle { radius } => { return 3.14159 * radius * radius; }
        Shape.Rect { width; height } => { return width * height; }
        Shape.Point => { return 0.0; }
    }
}

Match arms use Type.Variant { field_bindings } syntax. Fields are bound by name. Use .. to ignore remaining fields, or : alias to rename a binding.

Strings, structs and containers in variants

Variants can hold managed values — strings, structs with strings, containers, even the union itself through a container:

tape
import collections from "collections";

pub tagged Value {
    Null;
    Num { n: f64; }
    Str { s: string; }
    Arr { items: Vec_Value; }
}

@tape { collections.gen_vec(Value); }

gen_vec emits pub functions that take the element type, so Value must be pub (see collections).

Such a union owns its payload: constructing one copies a variable into it (a freshly built value is moved in), assigning var b = a; deep-copies the current variant, reassigning frees the old payload, and the payload is freed when the union goes out of scope. Match bindings borrow the fields — copy one if it must outlive the arm. See Ownership.

Unions from other modules

A pub tagged union of another module is named through the import, in every position:

tape
import json from "json";

let num: json.Value = json.Value.Number { v: 2.5 };
let n: json.Value = json.Value.Null;
match (num) {
    json.Value.Number { v } => { io.println("{v}"); }
    _ => {}
}
if (n is json.Value.Null) { io.println("null"); }

Its pub methods are called as usual: num.as_f64().

Type narrowing with is

tape
if (s is Shape.Circle) {
    io.println("{s.radius}");   // narrowed: the Circle's fields are readable
}

s is Shape.Circle tests which variant is active. Inside the if or while body it guards, s is narrowed to that variant and its fields can be read directly, methods included: with a names: Vec_string field, m.names.len() and m.names.get(0) work as they do on a match arm’s binding.

Untagged unions (t0/t1 only)

tape
union Register {
    i: i64;
    f: f64;
    bytes: [8]u8;
}

Like C’s union — all fields occupy the same memory, no discriminant tag. You’re responsible for knowing which interpretation is valid. Only available in t0/t1 profiles; no managed types (strings, slices, GC pointers) allowed inside. Use tagged unions for the safe “one of several types” case.

Last modified: