SCUA

Manual

Enums

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

Data is converted for you where it becomes a record: a record field typed Dir accepts 2 or "South" as Dir.South, whether the record comes from a typed decoder (json.decode(text, R)), sys.parse_args or parse(value, R) (see Enums in JSON). Code is not: a parameter or a let typed Dir still needs a real Dir, so convert with from_int there.

#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

#A record payload keeps the record's checks

When a variant carries a record, the record is checked exactly as it would be anywhere else: fields with a = default are filled, where refinements run, and a field the record does not declare is a compile error. There is nothing to remember here — a value behind a variant behaves like the same value bound to a typed name.

record Label {
  at: { int } where len(at) == 2,
  text: string,
  size: int where size > 0 = 12,
}
enum Item { Text(Label), Spacer }

let it = Item.Text({ at = [72, 700], text = "hello" })
match it
  Item.Text(l) -> print(`{l.text} at size {l.size}`)
  Item.Spacer  -> print("spacer")
end

try
  let bad = Item.Text({ at = [72], text = "hello" })
  print("accepted")
rescue e
  print(e.message)
end
$ scua label.scua
hello at size 12
payload of Item.Text: value.at violates Label's where rule (got [72])

The size field was not given and came back as its default, 12. The second construction gave at one number where the refinement asks for two, so it was refused where it was built rather than somewhere later.

#An enum-typed parameter or binding is checked

A parameter or let declared with an enum type must hold a real variant of that enum. A number or a name is refused, even one the enum has, and so is a variant of a different enum. Convert on purpose with from_int:

enum Dir { North, East }
enum Side { Left, Right }
fn describe_dir(d: Dir) -> string
  return `going {d}`
end
fn via(x) return describe_dir(x) end
print(describe_dir(Dir.North), via(Dir.from_int(1)))
for bad in [1, "East", Side.Left] do
  try
    print(via(bad))
  rescue e
    print(e.message)
  end
end
$ scua dir_check.scua
going Dir.North going Dir.East
parameter 'd' is int, expected Dir
parameter 'd' is string, expected Dir
parameter 'd' is an enum variant, expected Dir

#Enums in JSON

A payload-free enum is written to JSON as its variant's name, and so is it in TOML. A flags set is written as its bitmask. Where data becomes a record, the field reads back from the variant's name or its number: "reason": "Replayed" and "reason": 1 both decode into Reason.Replayed, so data written as numbers still loads, and scua schema publishes both. The same holds for the typed decoders, sys.parse_args and parse(value, R). A flags field takes the bitmask (3) or a list of member names (["Read", "Write"]). A number or name the declaration doesn't have is refused, naming the ones it does:

import json
enum Reason { Malformed, Replayed, Wrong }
record Failure { reason: Reason, message: string }
fn read(text)
  match json.decode(text)
    Ok(t) -> do
      try
        let f: Failure = t
        print(f.reason)
      rescue e
        print(e.message)
      end
    end
    Error(e) -> print(e)
  end
end
let sent = json.encode({ reason = Reason.Replayed, message = "m" })
print(sent)
read(sent)
read(`\{"reason": 1, "message": "m"}`)
read(`\{"reason": "Late", "message": "m"}`)
$ scua reason.scua
{"reason":"Replayed","message":"m"}
Reason.Replayed
Reason.Replayed
binding 'f': f.reason names 'Late', which Reason does not have (one of: Malformed, Replayed, Wrong)

A name keeps its meaning when you add or reorder variants, and a number doesn't: inserting a variant before Replayed would make 1 mean something else. That is why the name is what goes on the wire.

A tagged enum crosses as an object: kind names the variant, and when the variant carries data, value holds it, as an array when there are several fields.

import json
enum Item { Text(string), Move(int, int), Quit }
print(json.encode(Item.Text("hi")))
print(json.encode(Item.Move(1, 2)))
print(json.encode(Item.Quit))
$ scua item_json.scua
{"kind":"Text","value":"hi"}
{"kind":"Move","value":[1,2]}
{"kind":"Quit"}

Coming back, that spelling is recognised wherever a record field is declared with the enum, including inside a { Item } array: the decoded object becomes the variant at the typed boundary, so you match on it like any other. A kind the enum does not have, or a value of the wrong size, is refused there, by field — and so is a value that does not fit the variant's payload type, so a variant carrying a record gets that record's defaults and refinements on the way in.

import json
enum Item { Text(string), Move(int, int), Quit }
record Doc { items: { Item } }
fn read(text)
  return json.decode(text, Doc)    -- decoding into `Doc` is where the objects become variants
end
fn show(doc)
  for it in doc.items do
    match it
      Item.Text(s)    -> print(`text {s}`)
      Item.Move(x, y) -> print(`move {x},{y}`)
      Item.Quit       -> print("quit")
    end
  end
end
match read("{\"items\": [{\"kind\": \"Text\", \"value\": \"hi\"}, {\"kind\": \"Move\", \"value\": [3, 4]}, {\"kind\": \"Quit\"}]}")
  Ok(doc) -> show(doc)
  Error(e) -> print(`not a document: {e.message}`)
end
$ scua item_decode.scua
text hi
move 3,4
quit

scua schema publishes such a field as a oneOf over the variants, each fixed on its kind, so a caller reading the schema knows exactly what to send. Ok(…) and Error(…) encode the same way.

#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.

#An enum variant in a condition

An enum value, with or without a payload, is true in a condition, like any other value that is there. So an enum-or-nil works as a presence test, and or supplies a default:

enum Tool { Hammer, Saw }
fn choose_tool(n)
  let t = Tool.try_from_int(n) or Tool.Hammer
  if Tool.try_from_int(n) then return `known: {t}` end
  return `unknown, using {t}`
end
print(choose_tool(1))
print(choose_tool(9))
$ scua pick.scua
known: Tool.Saw
unknown, using Tool.Hammer

This is different from a Result (Ok/Error) and a flags set, which a condition refuses because each has its own yes or no. An enum you declare yourself to mean success and failure is still an ordinary enum, so test it with == or match. Your variants can't be called Ok or Error: those names are reserved for results.

#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.

The reverse is also true: an enum belongs to the module that declares it. A Signal you declare in your own file is a different type from traffic.Signal even with the same variants, and two libraries can each declare an enum Outcome without colliding. Values print by their declared name either way. Don't keep a verbatim copy of a library's enum to match its values against; since 0.26 that copy matches nothing the library builds, and the compiler hints to delete it.

#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 attack_style(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(attack_style(newer))
$ scua evolve.scua
scua: evolve.scua:14: from_int: 3 names no variant of `Tool` — when a number may be unknown, `Tool.try_from_int(n)` gives nil instead

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 holds Colour.Green, and Colour no 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