Enums
An enum is a named type with a fixed set of variants. Each variant can be
a bare unit (like a C enum member), or it can carry a payload – turning the
type into a tagged union. A value of an enum type always knows which variant
it currently is; match, if let, and let ... else are how you branch on
that and pull the payload back out.
Unlike type aliases, which are structural, enum
is nominal: two enums with identically-shaped variants are still different
types.
Contents
- Declaration
- Construction
.Discriminantis- Enum and int conversion
matchif let/let else- Changing a payload
- Payloads cannot hold containers
unsafe: unchecked payload access- Generic enums
- Built-in
OptionandResult - Built-in game enums
Declaration
Discriminants (the integer tag backing each variant) auto-number from 0. An
explicit = N resets the counter – the next unannotated variant continues
from N + 1. Two variants that resolve to the same value are a
WS064 error.
enum Color { Red, Green, Blue } // 0, 1, 2
enum Status { Idle = 0, Running = 5, Done } // 0, 5, 6
enum Shape {
Empty, // unit variant
Circle(float), // positional payload
Rect(float, float),
Box { w: float, h: float }, // named payload
}
A single enum can freely mix unit, positional, and named variants. Whichever shape a variant is declared with is the shape it must be constructed and matched with – see Construction below.
Construction
Construct a variant with its qualified Enum.Variant path: a unit variant is
the bare path, a positional-payload variant takes (...), and a
named-payload variant takes { ... } (the shorthand { x, y } works in a
value position too, same as a record literal):
enum Shape { Empty, Circle(float), Box { w: float, h: float } }
let c = Shape.Empty
let s = Shape.Circle(5.0)
let b = Shape.Box { w: 1.0, h: 2.0 }
Using the wrong bracket form for a variant’s declared shape –
Shape.Circle { } for a positional variant, or Shape.Box(1.0, 2.0) for a
named one – is a WS065 error, so a variant’s shape stays
unambiguous for exhaustiveness checking and the LSP’s fill action.
The prelude variants
Some, None, Ok, and Err (the built-in Option/Result
variants) are available bare – no Option. / Result. qualifier needed:
let o = Some(42) // T inferred = int
let n: Option<int> = None // annotation fixes T when payload can't
None and Err carry no payload to infer their type parameters from, so a
bare let n = None has nothing to pin T. Give the binding a type
annotation (let n: Option<int> = None) or that’s a WS063
error.
.Discriminant
.Discriminant yields the variant’s tag as an int. On a variant path
(Enum.Variant.Discriminant) it’s a compile-time constant that costs no
gates; on a value it reads the tag currently stored in that value:
enum Shape { Empty, Circle(float), Rect(float, float) }
out d = Shape.Circle.Discriminant // compile-time int, = 1
enum Shape { Empty, Circle(float), Rect(float, float) }
static var s: Shape = Shape.Circle(5.0)
out matches = s.Discriminant == Shape.Rect.Discriminant // runtime read vs const
.Discriminant (and match) on a value that isn’t an enum is a
WS066 error.
is
value is Enum.Variant asks whether the value currently holds that variant and
yields a bool. It is the discriminant comparison above, spelled the way it
reads:
enum Shape { Empty, Circle(float), Rect(float, float) }
static var s: Shape = Shape.Circle(5.0)
in ready: bool
out round = s is Shape.Circle // same gates as the compare above
out other = !(s is Shape.Circle) // negate with `!`
out go = s is Shape.Rect && ready // binds like `==`
The test compares tags, so a payload variant answers on its tag alone and binds
nothing; match and if let are how a payload
comes back out. The right side must name a variant of the same enum: s is v
(two values) and s is Other.Empty (a different enum) are errors.
A test whose two sides are both compile-time constants folds away, so testing
a const value costs no gates.
Enum and int conversion
Two spellings move between an enum value and its integer tag.
value.ToInt() is an exact alias for .Discriminant: it yields the tag as
an int, on both a value and a variant path, and folds to the same
compile-time constant when the receiver is a variant path.
Enum.FromInt(n) goes the other way. It builds a value of Enum whose tag is
the int n, with every payload slot defaulted to its zero value. Here n
may be a runtime int, not just a constant. It sets only the tag, so it is
meaningful mainly for unit-only enums (C-like enums and the built-in game
enums). For a payload-carrying variant the payload reads back as zero. An n
that matches no variant’s discriminant leaves a value that no match arm
covers except a wildcard _.
enum Shape { Empty, Circle(float), Rect(float, float) }
in tag: int
static var s: Shape = Shape.Rect(1.0, 2.0)
// ToInt is Discriminant by another name.
out same = s.ToInt() == s.Discriminant // always true
out circle = Shape.Circle.ToInt() // compile-time int, = 1
// FromInt rebuilds a value from a (possibly runtime) tag.
let rebuilt = Shape.FromInt(tag)
out kind = match rebuilt {
Empty => 0,
Circle(r) => 1,
Rect(w, h) => 2,
}
EnumToInt(value) and IntToEnum(value, wrap?) are the gate-backed
twins of .ToInt() and Enum.FromInt(n), for routing through the game’s
“Enum to Integer” / “Integer to Enum” gates.
EnumToInt(value) requires an enum argument (an int or any non-enum
is a type error) and yields an int. When value is a compile-time-known enum
(a variant literal, or a const value) it folds to the discriminant literal
and emits no gate; a runtime enum value instead routes through the real
EnumToInt gate, fed by the value’s tag. For Wirescript’s record enums the
runtime gate is just reading the tag, but it is emitted so game/native enums go
through the real gate.
IntToEnum(value) is the reverse. Its result is an enum whose concrete
type comes from the use site (the annotated target or output type), exactly
like FromInt and null; with no enum-typed context it can’t tell which enum
the integer names, which is an error. A constant value folds to the enum
record directly; a runtime value routes through the real IntToEnum gate.
The optional wrap clamps an out-of-range tag into range.
enum Shape { Empty, Circle(float), Rect(float, float) }
in tag: int
static var s: Shape = Shape.Rect(1.0, 2.0)
// EnumToInt is the gate-backed twin of ToInt / Discriminant.
out folded = EnumToInt(Shape.Circle(1.0)) // compile-time: folds to 1, no gate
out live = EnumToInt(s) // runtime: routes through the gate
// IntToEnum is the gate-backed twin of FromInt; the result's enum type
// comes from the annotated target.
let back: Shape = IntToEnum(tag) // runtime int -> Shape via the gate
out kind = match back {
Empty => 0,
Circle(r) => 1,
Rect(w, h) => 2,
}
match
match branches on an enum value’s variant, binding any payload as it goes.
It works both as an expression (arms are values, comma-separated, and it
compiles to a Select tree) and as a statement (arms are blocks, and it
compiles to a Branch/Union tree). Arm patterns use bare variant names –
the scrutinee’s enum type is already known, so there’s no Enum. qualifier.
Expression form
enum Shape { Empty, Circle(float), Rect(float, float), Box { w: float, h: float } }
static var s: Shape = Shape.Circle(5.0)
out area = match s {
Circle(r) => 3.14159 * r * r,
Rect(w, h) => w * h,
Box { w, h } => w * h,
Empty => 0.0,
}
The arms’ result types follow the same
widening join as an if-then-else expression.
Statement form
Statement arms are blocks (no commas between them) and require exec context:
enum Shape { Empty, Circle(float) }
static var s: Shape = Shape.Circle(5.0)
var lastArea: float = 0.0
on ReadBrickGrid() {
match s {
Circle(r) => { lastArea = r * r }
Empty => { lastArea = 0.0 }
}
}
Exhaustiveness
A match must cover every variant of the scrutinee’s enum, or be capped with
a _ wildcard arm. An uncovered variant is a WS054 error
that names the missing pattern(s) in its message; an arm that can never run
because an earlier arm already covers everything it would match is a
WS061 warning.
enum Shape { Empty, Circle(float), Rect(float, float) }
static var s: Shape = Shape.Circle(5.0)
out area = match s {
Circle(r) => 3.14159 * r * r,
_ => 0.0,
}
Nested patterns
A pattern can nest into a variant’s payload, including into another enum, and exhaustiveness checking follows it down:
enum Opt { Some(int), None }
enum Tree { Leaf(int), Node(Opt) }
static var t: Tree = Tree.Leaf(0)
out val = match t {
Node(Some(x)) => x,
Node(None) => 0,
Leaf(n) => n,
}
A named-payload pattern can ignore the fields it doesn’t need with ..:
Box { w, .. } binds only w and drops h.
Scrutinee must be a value, not a port
The scrutinee of a match (and of if let / let else, below) has to be a
var, let, static var, mod/chip parameter, or const – something the
compiler can see as a compile-time record of the tag plus its payload
slots. A top-level enum-typed in input port does not decompose that way
(it lowers to a single scalar wire, not a record), so matching on one directly
emits an unwired placeholder instead of a real Select/Branch tree. If an enum
value needs to arrive from outside the unit, copy it into a var first and
match on that:
enum Shape { Empty, Circle(float), Rect(float, float) }
var s: Shape = Shape.Empty
in setCircle: exec
in radius: float
on setCircle {
s = Shape.Circle(radius)
}
out area = match s {
Circle(r) => r * r,
Rect(w, h) => w * h,
Empty => 0.0,
}
(The same gap applies in the other direction: an enum value can’t yet drive a
top-level out port directly either – expose a derived scalar, such as
.Discriminant or a match result, instead.)
A let that ALIASES an enum in storage decomposes too, so an array element, a
map value, and a record field all match directly (match arr[i] { .. }, or
let e = h.shape first). The one spelling that does not is m.get(k): that
method returns a {Value, Found} result record rather than the value, which is
the WS066 “requires an enum scrutinee” error naming that
shape. Subscript it (m[k]) or read the member (m.get(k).Value).
if let / let else
These are single-variant refutable binds – shorthand for a match with one
real arm.
if let PATTERN = scrutinee { ... } runs the then block (with the pattern’s
bindings in scope) only when the scrutinee is that variant; the else is
optional:
enum Opt { Some(int), None }
static var o: Opt = Opt.Some(5)
var result: int = 0
on ReadBrickGrid() {
if let Some(x) = o {
result = x
} else {
result = -1
}
}
let PATTERN = scrutinee else { ... } binds into the surrounding scope
instead of a nested block, which is why its else is required to
diverge – it must end in return / emit, or be an if/match whose
arms all diverge. That’s what guarantees the binding is always available
after the statement. A non-diverging else is a WS062
error.
enum Opt { Some(int), None }
mod unwrapOr(v: Opt, fallback: int) -> int {
let Some(x) = v else { return fallback }
return x
}
static var o: Opt = Opt.Some(5)
var result: int = 0
on ReadBrickGrid() {
result = unwrapOr(o, -1)
}
Changing a payload
A payload field has no direct write. s.field = v on an enum value is a
WS007 error, because an enum value is stored as its tag
plus one slot per variant field, and the surface field name names no slot.
There are two ways to change one, and which you want depends on whether the value is already the variant you are writing.
Destructure and assign the capture. A match arm, an if let, or a
let ... else binds each capture directly to the matched value’s payload
slot, so writing the capture writes the payload in place. The other
fields, and the tag, are left alone:
type Track = { origin: vector, direction: vector }
enum Nav {
Idle,
Moving { track: Track, label: string, active: bool },
}
var nav: Nav = Nav.Moving {
track: { origin: Vec(4.0, 4.0, 4.0), direction: Vec(0.707, 0.707, 0.0) },
label: "start",
active: false,
}
in go: exec
on go {
if let Moving { track, label, active } = nav {
track = { origin: Vec(69.0, 69.0, 69.0), direction: Vec(1.0, 0.0, 0.0) }
label = "moved"
active = true
}
}
The write costs one Var_Set per scalar field, and one per leaf for a
record-typed field, so track above costs two, one for origin and one for
direction. That is the same price as writing a record var. Because the
assignment sits inside the arm, it only runs when the value really is that
variant, which is what makes an in-place payload write safe.
The scrutinee has to be writable storage for this: a var, a static var,
a *T parameter, or a var-backed record field. Captures off a let, a
const, or a by-value parameter stay read-only, since there is no storage gate
behind their slots for a write to land on.
A container element is not storage. arr[i], m[k], and any field reached
through one (hs[0].e) read the element out by value, so a capture off them is
read-only too and writing it is a WS007 error. To change an
element’s payload, copy it into a var, mutate that, and store it back:
enum Slot { Empty, Full { label: string, ready: bool } }
var slots: Slot[]
var cur: Slot = Slot.Empty
in go3: exec
on go3 {
slots.push(Slot.Full { label: "start", ready: false })
cur = slots[0]
if let Full { label, ready } = cur {
label = "moved"
ready = true
}
slots[0] = cur
}
Rebuild the whole variant. Assigning the enum itself sets the tag and every slot of the new variant at once, and is the only option when the value is changing variant:
enum Nav2 { Idle, Moving { label: string, active: bool } }
var nav2: Nav2 = Nav2.Idle
in go2: exec
on go2 {
nav2 = Nav2.Moving { label: "moved", active: true }
}
Reading works the same way round: pull a payload field out through a match
or if let capture, not through value.field.
Payloads cannot hold containers
A payload field cannot be an array or a map, and neither can a record used as
one. The declaration is a WS069 error:
enum Loadout {
Empty,
Carrying { items: int[] }, // WS069
}
A payload slot is a single storage gate, and it is filled by constructing
the variant. There is no way to construct a container into one: a declaration
initializer bakes an initial value, and an array has none, while a runtime
Loadout.Carrying { items: [1, 2, 3] } has no gate that assigns a whole array.
The slot would still be allocated, so a captured items.push(...) compiled to a
real gate writing storage that nothing ever filled, and the value read back
empty. Rejecting the declaration stops that at the source.
The same applies to a generic enum instantiated with a container, when the
parameter is one a variant actually stores, so Option<int[]> is a WS069 at
the annotation. A parameter no variant stores is unaffected.
Keep the container in its own var and put something scalar in the payload
that refers to it, such as an index, a key, or a length:
var items: int[]
enum Loadout {
Empty,
Carrying { first: int, count: int },
}
var loadout: Loadout = Loadout.Empty
in pickUp: exec
on pickUp {
items.push(7)
loadout = Loadout.Carrying { first: 0, count: items.length() }
}
unsafe: unchecked payload access
unsafe <value>.<Variant>.<field> reads or writes one payload slot without
proving the value is that variant. It costs no gate: the access resolves
straight to the slot.
enum Job { Idle, Running { label: string, ticks: int } }
var job: Job = Job.Running { label: "start", ticks: 0 }
var seen: string = ""
in tick: exec
on tick {
unsafe job.Running.ticks = unsafe job.Running.ticks + 1
seen = unsafe job.Running.label
}
The variant is required and is what selects the slot, so a field two variants
share is never ambiguous. A record-typed payload keeps projecting
(unsafe job.Running.spec.width). An unknown variant is a
WS060, an unknown field a WS010, a missing segment a
WS070.
The tag is asserted, never tested and never written:
- Reading a variant the value is not returns that slot’s stale contents, with no error and no default.
- Writing sets the slot only. If
jobisIdle,unsafe job.Running.ticks = 5fillsRunning’s slot whilejobstaysIdle, and a latermatchstill takes theIdlearm.
Prefer a destructure, which tests the tag first; reach for unsafe when you
have already established the variant.
unsafe is contextual, so a variable, parameter, or mod named unsafe keeps
working. (The tree-sitter grammar cannot express that and treats the word as a
keyword, so an editor using it flags such a name even though it compiles.)
Generic enums
An enum can take type parameters, instantiated per use exactly like a generic type alias:
enum Box<T> { Value(T), Empty }
static var b: Box<int> = Box.Value(42)
out val = match b {
Value(x) => x,
Empty => 0,
}
Built-in Option and Result
Wirescript ships Option<T> and Result<T, E> as prelude enums – no
declaration needed, and their variants are usable bare (Some/None/Ok/Err,
see Construction above). They’re built in as if declared:
enum Option<T> { Some(T), None }
enum Result<T, E> { Ok(T), Err(E) }
Don’t redeclare them yourself – the prelude already registers both names, so
an enum Option<T> { ... } of your own is a WS013
duplicate-declaration error.
static var maybe: Option<int> = Some(7)
static var missing: Option<int> = None
out found = match maybe { Some(x) => x, None => -1 }
static var r: Result<int, string> = Ok(200)
out status = match r {
Ok(code) => code,
Err(msg) => -1,
}
Built-in game enums
A handful of enum types are built into the compiler with no enum
declaration at all. EasingFunction, Direction, ColorSpace,
DisplayTextJustification, TextTypeface, DisplayTextEasing, and
EasingDirection are the ones shipping today. They are not hand-maintained:
the compiler discovers them from the game’s own config enums (the same ones
that back a gate’s settings-menu fields), so the exact set and its variants
track whatever build the compiler was generated against.
A built-in game enum behaves like an ordinary unit-only enum (see
Declaration above): construct a variant with its qualified
path, store it in a var or static var, and read .Discriminant:
static var mode: EasingFunction = EasingFunction.Bounce
out disc = mode.Discriminant
The enum value is the default representation; .Discriminant gives back the
integer the game’s own schema assigns that member. That is the one place a
built-in game enum differs from a user-declared one: a user enum’s
discriminants auto-number from 0, but a built-in game enum’s discriminant is
the real schema value, since it round-trips through saved component data and
a renumbered tag would write the wrong value to the game.
A built-in enum value also passes directly as the matching gate config argument:
in t: float
let eased = Easing(0.0, 1.0, t, function = EasingFunction.Bounce, direction = EasingDirection.InOut)
out result = eased
The older bare-name form (an unqualified member name) still works side by side with the enum-qualified form, and both set the same config field:
in t: float
let eased = Easing(0.0, 1.0, t, function = Bounce, direction = InOut)
out result = eased