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.