SCUA

Manual

Records and gradual types

SCUA is dynamic by default. You can write a whole program with no type annotations and it runs like any scripting language. Annotations are something you add where a compile-time check pays off, usually around the data that matters: saved state, messages between systems, the shape of an entity.

The rule that makes this work: annotations are erased. They tell the compiler what to check, and then they're gone. A correct program runs exactly the same with or without them. Adding a type never changes behaviour; it only adds a check that catches mistakes earlier.

#A record

A record declares a named type with a fixed set of fields:

record Player {
  name:  string,
  hp:    number,
  level: number = 1,
}

let hero: Player = { name = "Ed", hp = 100 }
print(`{hero.name}: {hero.hp} hp, level {hero.level}`)

Run it:

$ scua hero.scua
Ed: 100 hp, level 1

The literal is checked field by field against Player: every required field present, no unknown keys, each value the right type. Records are structural, so any table with the right fields fits the type; you don't construct them with a special syntax. (If you reach for Player{ … } out of habit from another language, the compiler reminds you: a record type isn't a value, so build one by assigning a table literal to a typed binding as above.)

#Field defaults and optional fields

A field with a default (level: number = 1) can be left out at construction. It's auto-filled, which is also how older saved data that predates the field gets a sensible value when loaded.

A field typed with a trailing ? is optional. It may be omitted, and reads back as nil:

record Hero {
  name:  string,
  title: string?,   -- optional
  level: number = 1 -- default
}
let a: Hero = { name = "Ed" }
let b: Hero = { name = "Mara", title = "the Bold", level = 9 }
print(`{a.name}, level {a.level}, title {a.title}`)
print(`{b.name}, level {b.level}, title {b.title}`)
$ scua optional.scua
Ed, level 1, title nil
Mara, level 9, title the Bold

A default gives an omitted field a value. An optional field is allowed to be absent. They're different tools: reach for a default when there's a sensible value, an optional when absence is meaningful.

#Type aliases

type names a structural shape without declaring a sealed record. Use it for shapes you want to refer to in several places:

type Profile = { name: string, title: string? }

let p: Profile = { name = "Ana" }   -- title omitted
print(p.name)
$ scua profile.scua
Ana

#What gets checked

Typed bindings, parameters, and return types are all checked at compile time. Field access flows the field's type, so a chain of accesses stays checked. Introduce a mistake and the compiler locates it before the program runs:

record Player { name: string, hp: number }
let hero: Player = { name = "Ed", hp = "lots" }
print(hero.name)
$ scua mistake.scua
scua: mistake.scua:2: type error: field 'hp' is string, expected number

The same goes for arguments at a call site:

fn restore(p: number, amount: number) -> number
  return p + amount
end
print(restore("hello", 5))
$ scua arg.scua
scua: arg.scua:4: type error: argument 1 to 'restore' is string, expected number

Untyped code stays fully dynamic and pays nothing. A table with no annotation is never shape-checked; you can grow it freely.

#The boundary rule: parse, don't validate

Loose data — anything typed any, such as host input or a freshly loaded save — gets checked when it's assigned to a value of a record type. After that, the typed code trusts it. The check confirms each declared field is present and, for scalar fields (int, float, string, bool, decimal, big, bytes, key), that the value is of the right type — so a string can't slip into the record's int field. It also runs any migrate hook and where refinement on the record.

The check recurses through declared structure: a field that is itself a record, and the elements of a typed { Record } collection, are checked, migrated, and refined the same way — all the way down, including self-recursive types like a linked list or tree (Node { value: int, next: Node? }). So corrupt or stale data nested anywhere inside a loaded value — at any depth — is caught at the level it lives, not waved through, and a migrate hook or where refinement runs at every node, not just the top one. What it does not descend into is a field typed any or a scalar collection like { int } — there's no declared shape there to check, so those stay trusted until they cross a boundary of their own. The one bound: a cyclic value (one that points back at itself), or one nested deeper than a few hundred levels, faults at the boundary rather than loading — a cycle can't be persisted anyway, so it's rejected rather than trusted.

record Player { name: string, hp: number, inventory: { string } }

let incoming: any = { name = "Ana", hp = 80, inventory = ["bow"] }
let ana: Player = incoming   -- shallow-checked here, trusted afterward
print(`recruited {ana.name} with {ana.hp} hp`)
$ scua boundary.scua
recruited Ana with 80 hp

If incoming were missing hp, the assignment would be rejected right at that line. This is the "parse, don't validate" idea: do the check once at the edge of the typed world, turn loose data into a trusted value, and stop re-checking it everywhere downstream.

This runtime re-check is for record crossings specifically. A plain scalar annotation — a binding like let n: int = x, a parameter fn f(n: int), or a return type — is a hint to the checker, erased before the program runs (Functions); it is not re-validated at runtime. So if x is typed any and actually holds a string, let n: int = x does not fault, and n keeps the value's real (string) kind — which can then surprise a later == or table index. When loose data must be enforced at the edge, give it a record type (so the field check runs) or test it yourself before trusting it.

A rejected crossing is a fault, not an Error value. The ? operator only unwraps Ok/Error, so it won't catch a boundary rejection. If the loose data might be malformed, wrap the crossing in try … rescue (or use pcall) rather than relying on ?. This matters when the data and the typing arrive together: for example json.decode(body)? gives you an Ok value, but the let order: Order = decoded that follows can still fault, so that line needs a try of its own.

#Records are sealed

At runtime a record is a fixed-layout instance. Updating a declared field is fine. Adding a field that wasn't declared is not.

If the compiler can see the type, it catches the bad write directly:

record Player { name: string, hp: number }
let hero: Player = { name = "Ed", hp = 100 }
hero.hp = 90          -- ok: declared field
hero.trophies = 5     -- undeclared
$ scua sealed.scua
scua: sealed.scua:4: type error: unknown field 'trophies' on Player

When a record reaches code that has lost sight of its type, for instance an untyped function parameter, the seal still holds at runtime. Adding an undeclared field raises a fault you can catch:

record Player { name: string, hp: number }
let hero: Player = { name = "Ed", hp = 100 }

fn tamper(x)            -- x is untyped, so the checker can't see the shape
  x.trophies = 5
end

try
  tamper(hero)
rescue e
  print(`caught: {e}`)
end
$ scua sealed-runtime.scua
caught: cannot add a field to a sealed record

A plain untyped table, by contrast, grows as much as you like. The seal is a property of records, not of every table.

#Function types

A parameter, field, or binding can be typed with a function signature fn(T) -> R. The argument you pass must match the signature, and a call through a function value checks its arguments and yields the declared return type:

fn apply(f: fn(int) -> int, x: int) -> int
  return f(x)
end
fn double(n: int) -> int return n * 2 end
print(apply(double, 21))

let combine: fn(int, int) -> int = fn(a: int, b: int) -> int return a + b end
print(combine(40, 2))
$ scua fntype.scua
42
42

Still gradual: an unannotated function (its parameters are any) satisfies any function type, and fully untyped higher-order code is never checked.

#Evolving a schema

Saved data outlives the code that wrote it, so records have to handle older shapes.

Additive changes are automatic. Add a field with a default and old saves that lack it get the default on load. No extra code.

Non-additive changes, like renaming or splitting a field, need a migrate hook. It takes the old data and returns the current shape. It runs only when the data isn't already complete, so current data skips it:

record Account { handle: string, gold: number }
migrate Account(old)
  return { handle = old.name ?? old.handle, gold = old.gold }
end

let v1save: any = { name = "ed", gold = 500 }   -- old save, pre-rename
let acct: Account = v1save                       -- the hook upgrades it
print(`account {acct.handle} has {acct.gold} gold`)
$ scua migrate.scua
account ed has 500 gold

Here v1 stored name and v2 renamed it to handle. The ?? operator picks the first non-nil value, so the hook reads old.name from old saves and old.handle from anything already current.

#See also

  • Pattern matching — destructuring records and tables.
  • Enums — closed sets of named variants.
  • Audience gatesgated fields and project, for sending an audience-safe view.
  • Contractswhere refinements that constrain field values at the boundary.
  • Errors and faultstry/rescue and the sealed-record fault.