Skip to content

Unions

Combine existing types into an anonymous closed alternative with `A | B`, and consume one by matching its members.

Updated View as Markdown

A | B is an anonymous closed union: a value is exactly one of the listed types. Unlike an enum, there are no variant names to invent — the member types are the alternatives.

struct Cash { amount: int }
struct Card { last4: string }

fn describe(p: Cash | Card): string {
    return match p {
        Cash(c) => "cash ${c.amount}"
        Card(c) => "card ending ${c.last4}"
    }
}

fn f(): string {
    return describe(Card { last4: "4242" })
}

Behind the scenes the compiler materializes a synthetic enum (it appears in diagnostics as $Anon<Cash,Card>) with one variant per member type.

Injection

A value of a member type flows into an expected union position — a parameter, an annotated binding, or a declared return type — and the compiler wraps it.

struct Cash { amount: int }
struct Card { last4: string }

fn pick(use_card: bool): Cash | Card {
    if use_card {
        return Card { last4: "0000" }
    }
    return Cash { amount: 500 }
}

fn f(): string {
    p: Cash | Card = Cash { amount: 20 }
    return match p {
        Cash(c) => "${c.amount}"
        Card(c) => c.last4
    } + match pick(true) {
        Cash(c) => "${c.amount}"
        Card(c) => c.last4
    }
}

The injection is only valid where a union is expected. It does not make the member types generally assignable to one another.

Matching

Each member gets one arm, written as the type name with a binding for the value of that type. The binding has the member’s own type inside the arm.

struct Ok200 { body: string }
struct Redirect { location: string }
struct Failure { code: int }

fn render(r: Ok200 | Redirect | Failure): string {
    return match r {
        Ok200(o) => o.body
        Redirect(x) => "-> ${x.location}"
        Failure(e) => "error ${e.code}"
    }
}

fn f(): string {
    return render(Redirect { location: "/home" })
}

Guards work as they do everywhere else, and a wildcard covers the members you did not name:

struct Cash { amount: int }
struct Card { last4: string }

fn is_large(p: Cash | Card): bool {
    return match p {
        Cash(c) if c.amount > 10000 => true
        _ => false
    }
}

fn f(): bool { return is_large(Cash { amount: 5 }) }

An enum can be a union member, in which case the arm binds the whole enum value and you match it again inside:

struct Payload { bytes: int }
enum Control { Ping, Close }

fn cost(frame: Payload | Control): int {
    return match frame {
        Payload(p) => p.bytes
        Control(c) => match c {
            Ping => 1
            Close => 0
        }
    }
}

fn f(): int { return cost(Control.Ping) }

Canonicalization

Member order does not create a different type — A | B and B | A are the same, and a value of one is accepted where the other is expected.

struct A { x: int }
struct B { y: int }

fn read(v: A | B): int {
    return match v { A(a) => a.x, B(b) => b.y }
}

fn read_reversed(v: B | A): int {
    return read(v)
}

fn f(): int { return read_reversed(A { x: 3 }) }

Duplicate members collapse, so A | A is simply A:

struct A { x: int }

fn f(v: A | A): int {
    return v.x
}

Nesting, however, is not flattened. A | (B | C) is a two-member union whose second member is itself a union, and it does not unify with the three-member A | B | C:

struct A { x: int }
struct B { y: int }
struct C { z: int }

fn nested(v: A | (B | C)): int { return 0 }

fn f(v: A | B | C): int { return nested(v) }

Write the flat form when you mean a flat union.

Precedence

| binds loosely, and the list sugar []T binds to the type immediately after it. So []A | B is ([]A) | B — a union of “list of A” and “B”, which is almost never what was meant:

struct A { x: int }
struct B { y: int }

fn f(): int {
    xs: []A | B = [A { x: 1 }]
    return xs.len()
}

Parenthesize, or use the List[...] spelling:

struct A { x: int }
struct B { y: int }

fn sum_a(xs: [](A | B)): int {
    mut n := 0
    for v in xs {
        n += match v { A(a) => a.x, B(b) => b.y }
    }
    return n
}

fn f(): int {
    boxed: List[A | B] = [A { x: 1 }, B { y: 2 }]
    return sum_a(boxed)
}

A union also nests inside Option and other generics:

struct A { x: int }
struct B { y: int }

fn f(): int {
    maybe: (A | B)? = A { x: 7 }
    return match maybe {
        Some(v) => match v { A(a) => a.x, B(b) => b.y }
        None => 0
    }
}

Naming a union

A type alias gives a union a readable name without changing its identity, and a receiver-prefix function attaches behavior to that name.

struct Cash { amount: int }
struct Card { last4: string }

type Payment = Cash | Card

fn Payment.summary(self): string {
    return match self {
        Cash(c) => "cash ${c.amount}"
        Card(c) => "card ${c.last4}"
    }
}

fn f(): string {
    p: Payment = Card { last4: "4242" }
    return p.summary()
}

Because the alias is transparent, Payment and Cash | Card remain the same type. Two aliases with the same members are therefore interchangeable — unless you mark them distinct:

struct A { x: int }
struct B { y: int }

distinct type InputValue = A | B
distinct type OutputValue = A | B

fn read(v: InputValue): int { return 0 }

fn f(v: OutputValue): int { return read(v) }

A distinct union still accepts member injection, so it is usable as a domain type rather than only as a signature:

struct A { x: int }
struct B { y: int }

distinct type InputValue = A | B

fn read(v: InputValue): int {
    return match v { A(a) => a.x, B(b) => b.y }
}

fn f(): int {
    v: InputValue = B { y: 9 }
    return read(v)
}

Shared fields and methods

When every member exposes the same field or method, you can reach it directly without matching first:

struct Person { id: int, name: string }
struct Device { id: int, serial: string }

fn identifier(v: Person | Device): int {
    return v.id
}

fn f(): int {
    return identifier(Device { id: 12, serial: "x1" })
}
struct Rect { w: float, h: float }
struct Circle { r: float }

impl Rect { fn area(self): float => self.w * self.h }
impl Circle { fn area(self): float => 3.14159 * self.r * self.r }

fn total(shapes: [](Rect | Circle)): float {
    mut sum := 0.0
    for s in shapes {
        sum += s.area()
    }
    return sum
}

fn f(): float {
    return total([Rect { w: 2.0, h: 3.0 }, Circle { r: 1.0 }])
}

If no member has the method at all, the call is rejected:

struct Rect { w: float, h: float }
struct Circle { r: float }

impl Rect { fn area(self): float => self.w * self.h }

fn total(s: Rect | Circle): float { return s.area() }

Error unions

error Name = A | B composes declared error types into one union, which is how a service layer states “any failure from these two subsystems”. Its variants are matched directly, without naming the sub-error type.

error BookingError { Full, Closed }
error QueryError { Timeout }

error ServiceError = BookingError | QueryError

fn book(seat: int): int ! BookingError {
    if seat < 0 { error Closed }
    error Full
}

fn reserve(seat: int): int ! ServiceError {
    return book(seat)?
}

fn attempt(seat: int): string {
    reserve(seat) catch {
        Full => return "sold out"
        Closed => return "not open yet"
        Timeout => return "backend slow"
    }
    return "reserved"
}

See Error Unions for the propagation rules.

Choosing between a union and an enum

Use an anonymous union when the member types already say what the alternatives mean, and each one appears exactly once.

Use a named enum when:

  • two alternatives would need the same payload type but mean different things;
  • the alternatives need stable names that survive refactoring;
  • payload fields need per-variant labels;
  • methods, derives, or a serialized tag mapping belong to the sum itself;
  • you want the exhaustiveness guarantee on future additions.

A composed example

An HTTP-ish router whose handler result is a union of three response shapes, consumed once by the renderer and once by a logging helper.

struct Body { content: string }
struct Redirect { location: string, permanent: bool }
struct Failure { code: int, reason: string }

type Response = Body | Redirect | Failure

fn route(path: string): Response {
    if path == "/" {
        return Body { content: "welcome" }
    }
    if path == "/old" {
        return Redirect { location: "/", permanent: true }
    }
    return Failure { code: 404, reason: "no such path" }
}

fn Response.status(self): int {
    return match self {
        Body(_) => 200
        Redirect(r) => if r.permanent { 301 } else { 302 }
        Failure(f) => f.code
    }
}

fn render(r: Response): string {
    return match r {
        Body(b) => b.content
        Redirect(r2) => "Location: ${r2.location}"
        Failure(f) => "${f.code} ${f.reason}"
    }
}

fn serve(paths: []string): string {
    mut log := ""
    for p in paths {
        r := route(p)
        log += "${p} -> ${r.status()}: ${render(r)}\n"
    }
    return log
}

fn f(): string {
    return serve(["/", "/old", "/missing"])
}

Response is a name for Body | Redirect | Failure, nothing more: route can return any member directly, and both consumers match the same three arms.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close