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 gates —
gatedfields andproject, for sending an audience-safe view. - Contracts —
whererefinements that constrain field values at the boundary. - Errors and faults —
try/rescueand the sealed-record fault.