An enum is a closed set of named variants. It's the type
you reach for when a value is one of a fixed handful of things: a
direction, an input event, an AI state. Because the set is closed, the
compiler can check that a match handles every
case.
enum Dir {
North,
East,
South,
West,
}
print(`North = {Dir.North}, West = {Dir.West}`)
Run it:
$ scua dir.scua
North = Dir.North, West = Dir.West
A variant prints as what it is, not as the number behind it — in log
lines, in tostring, inside a printed array or table, and in
error messages. That is the difference between reading
state = Phase.Combat in a crash report and reading
state = 2.
You construct a variant through the enum name,
Dir.North, so the reader always sees which type it is. Each
enum is its own type; mixing two different enums is a compile error.
#Payload-free variants have a number behind them
A variant that carries no data has an integer discriminant, auto-numbered from 0. The value knows which enum it belongs to, so it prints by name and never compares equal to a variant of a different enum — but the number is still there whenever you use the variant as a position: it indexes arrays directly, and it does arithmetic. That is what makes per-direction step tables, sprite lookups and turn order work.
The rule is worth stating once, because everything else follows from it:
An array index is a position; a table key is a value.
So steps[Dir.North] indexes with the number, while
counts[Dir.North] and counts[0] are two
different table keys — the second is an int, and an int is not
a direction.
You can set explicit discriminants. A bare variant continues from the previous one plus one:
enum Key {
Escape = 27,
Space = 32,
Enter,
}
print(`Escape = {Key.Escape}, Space = {Key.Space}, Enter = {Key.Enter}`)
$ scua key.scua
Escape = Key.Escape, Space = Key.Space, Enter = Key.Enter
Enter got 33 because it follows Space = 32
— use Key.Enter.to_int() when you want to see or send the
number.
When you genuinely need to cross the integer boundary, for a C
library, the wire, or a save file, the conversion is explicit.
.to_int() goes out, Enum.from_int(n) comes
back:
enum Dir { North, East, South, West }
print(`East index = {Dir.East.to_int()}`)
let restored = Dir.from_int(2)
print(`from_int(2) is South? {restored == Dir.South}`)
$ scua convert.scua
East index = 1
from_int(2) is South? true
#Variants that carry data
A variant can carry a positional payload. As soon as one does, the whole enum is a tagged sum type: the data rides with the tag, so there's no separate "when it's this state, that field means something" table to keep in sync.
enum Input {
Quit,
Key(int),
Click(int),
}
fn act(e)
match e
Input.Quit -> return "bye"
Input.Key(c) -> return `pressed key {c}`
Input.Click(b) -> return `clicked button {b}`
end
end
print(act(Input.Quit))
print(act(Input.Key(65)))
print(act(Input.Click(2)))
$ scua input.scua
bye
pressed key 65
clicked button 2
A payload can have several fields, and you can name them. The names are documentation. You still match by position:
enum AIState {
Idle,
Chase(target: int, dist: int), -- names are documentation, matched positionally
}
match AIState.Chase(42, 3)
AIState.Idle -> print("idle")
AIState.Chase(t, d) -> print(`chase {t} at range {d}`)
end
$ scua chase.scua
chase 42 at range 3
#Matching and exhaustiveness
match discriminates the variants, and because an enum is
closed, the compiler checks the match is exhaustive. Drop a variant and
it names the gap:
enum Dir { North, East, South, West }
fn label(d)
match d
Dir.North -> return "up"
Dir.East -> return "right"
Dir.South -> return "down"
end
end
print(label(Dir.North))
$ scua label.scua
scua: label.scua:3: type error: match on Dir is not exhaustive — missing: West
That check is the main reason to use an enum over a set of loose constants: add a variant, and every match that needs attention turns into a compile error. See pattern matching for the full set of arm shapes.
#Using an enum from another file
An enum belongs to whoever declared it, and other files can name it.
Declare it once in a module and reach it through the import alias —
alias.Enum.Variant — in patterns and in expressions
alike:
import traffic
match traffic.next(1)
traffic.Signal.Go -> print("go")
traffic.Signal.Caution(secs) -> print(`caution for {secs}s`)
traffic.Signal.Stop(reason) -> print(`stop: {reason}`)
end
Exhaustiveness works across the boundary too: drop the
Stop arm and the compiler names the variant you forgot,
exactly as it would for an enum declared in this file. That is the point
— it is the check you were buying by using an enum at all, and until now
it stopped at the file edge.
When a match has several arms, type gives the enum a
shorter local name:
import traffic
type Signal = traffic.Signal
match traffic.next(1)
Signal.Go -> print("go")
Signal.Caution(secs) -> print(`caution for {secs}s`)
Signal.Stop(reason) -> print(`stop: {reason}`)
end
Signal here is an abbreviation, not a new
type. It is traffic.Signal: you can mix
the two spellings in one match,
Signal.Go == traffic.Signal.Go is true, and a value you
build with either is the value the library builds. See modules for
the module side, and examples/module_enums.scua for the
whole thing running.
#A sharp edge: evolving a persisted enum
There is no versioning or reserve story for enums yet. That matters the moment an enum value is saved or sent over the wire.
Say an older build knows three variants and matches them
exhaustively. A newer build adds a fourth and sends a value carrying it.
The old build refuses it at the point it is built, naming the enum and
the number — rather than accepting it and failing later, somewhere else,
inside a match:
enum Tool {
Sword,
Bow,
Staff,
}
fn describe(t)
match t
Tool.Sword -> return "melee"
Tool.Bow -> return "ranged"
Tool.Staff -> return "magic"
end
end
-- A value arrives carrying a discriminant this build doesn't know.
let newer = Tool.from_int(3)
print(describe(newer))
$ scua evolve.scua
scua: evolve.scua:14: from_int: 3 names no variant of `Tool`
The failure lands on the line that made the bad value, not on the
match three frames away — and it says which enum and which
number, which is enough to act on. Use Tool.try_from_int(3)
instead when a number arriving from outside is expected to be
unknown sometimes: it answers nil rather than faulting, and
you decide what to do.
#Saves record the name, not the number
That hazard is about a number arriving from
somewhere — a wire, a C library, a file you parse yourself. It is not
about durable saves, which record an enum value by
name:
- Reordering or renumbering a declaration is safe. A save written before the edit still means what it meant.
- Appending a variant is safe, as it always was.
- Renaming or removing one is refused, with a message
that names it:
this save holdsColour.Green, andColourno longer has a variant of that name. If you need to rename a variant that is already in players' saves, bump your schema version and migrate on load.
Cluster values behave the same way. That matters most during a rolling deploy, when two versions of your code are running at once by design.
Until there's a proper versioning mechanism for values that arrive as
raw numbers, treat those with care: pin the discriminants you send over
a wire, only add variants at the end, and check incoming numbers with
try_from_int instead of assuming.
#See also
- Pattern matching — matching variants and exhaustiveness.
- Records and gradual types — the other way to shape structured data.
- Errors and faults — what a no-match fault is and how to recover from one.