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.

A field default fills a field that is missing. Writing nil explicitly keeps the nil:

record Cfg { host: any = "localhost" }
let a: Cfg = { }              -- host is "localhost"
let b: Cfg = { host = nil }   -- host is nil

That differs from a parameter default, which fills a missing or nil argument. The reason is that a record is something you can ask about — a field can be there or not, and both can mean something — while a parameter always holds a value once the call starts.

#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

A record literal written where the record type is declared builds a real record: a typed let (let p: Player = { … }), a return from a function declared -> Player, a field of that type, an element pushed into or stored in a { Player }, or an argument to a Player parameter of a function in the same file. Fields with a default, or marked optional, may be left out; the runtime fills them in (see Literals become records):

record Player { name: string, hp: number, mana: number = 100 }
fn heal(p: Player) -> number
  return p.hp + p.mana
end
print(heal({ name = "Ed", hp = 10 }))
$ scua literal.scua
110

When the literal is wrong, the error names the field, with the same hint a typed let gives. A misspelt field is also a missing one, so both are reported:

record Player { name: string, hp: number, mana: number = 100 }
fn heal(p: Player) -> number
  return p.hp + p.mana
end
print(heal({ naem = "Ed", hp = 10 }))
$ scua typo.scua
scua: typo.scua:5: type error: unknown field 'naem' on Player (did you mean 'name'?)
scua: typo.scua:5: type error: missing field 'name' of type string

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

#Every problem at once, and warnings

The checker reports everything it finds in one go, so you can fix a file in one pass: every error in file and line order, then the warnings. Any error stops the program before it starts.

record Player { name: string, hp: number }
let hero: Player = { name = "Ed", hp = "lots" }
let hp: int = hero.name
let n = tonumber([hero.hp])
print(hero.nam)
$ scua mistakes.scua
scua: mistakes.scua:2: type error: field 'hp' is string, expected number
scua: mistakes.scua:3: type error: value is string, but the binding is declared int
scua: mistakes.scua:5: type error: unknown field 'nam' on Player (did you mean 'name'?)
scua: mistakes.scua:4: warning: `tonumber` is given an array ({ number }), which is never a number, so this always gives nil — pass it a string or a number

A warning is code that runs but almost certainly doesn't do what it says. On its own it doesn't stop anything. To make it stop the run, start the file with --!strict: that file's warnings then fail the run as errors do, and nothing runs.

--!strict
let ports = [8080, 8081]
let port = tonumber(ports)
print("listening on", port)
$ scua strict.scua
scua: strict.scua:3: warning: `tonumber` is given an array ({ int }), which is never a number, so this always gives nil — pass it a string or a number
scua: nothing ran: 1 warning is in a --!strict file, where a warning fails the run

For a whole run, CI and agent harnesses pass --deny-warnings on the command line instead. Warnings in the packages you depend on are quiet unless you ask for them with --dep-warnings, and they never stop your run on their own.

When a log should hold only what stops the run, such as the output a tool or an agent reads line by line, pass --no-warnings. Errors still print and still stop the run; the warnings don't print:

let ports = [8080, 8081]
let port = tonumber(ports)
print("listening on", port)
$ scua --no-warnings quiet.scua
listening on nil

A warning in a --!strict file still prints, since that file made it an error and the run stops on it. Your editor keeps showing every warning.

To make that the default for everyone who runs a project, put it in the project's scua.toml:

[run]
warnings = false

The command line still wins: scua --warnings shows them, and --deny-warnings counts them as usual.

#The boundary rule: parse, don't validate

A type annotation on a parameter, a return or a let checks one level as the value crosses: its kind and, for a record, the kind of each of its own fields and its own where rule. parse(value, T), a decoder given a type and an encoder given a type check all the way down, running where rules at every level, and parse and the decoders also convert, filling defaults and running migrate hooks, without changing the value they are given.

#An annotation checks one level

For a record, one level means:

  • a scalar field (int, float, number, string, bool, decimal, money, big, bytes, key) holds a value of that kind;
  • an enum or flags field holds a variant of that declaration;
  • a field typed as a record holds a table, and a collection field ({ string }, { Item }) holds an array. What is inside them isn't looked at;
  • a { f32 }-style numeric buffer holds values of its element type. One that already has that type is kept; any other array whose values fit is copied into a new buffer, which the field then holds.

An annotation never converts: no default is filled and no migrate runs, so a field the value lacks is refused even when it has a default. A record literal written where the type is declared is the exception, because it is built there (see Literals become records).

A shape type, written as a table ({ id: string, n: int }) or named with type R = { id: string }, is checked the same way: the value must be a table holding each declared field at its kind. Extra fields are allowed, and a literal of a shape type stays a plain table rather than becoming a record. A map type such as { [string]: int } names no fields, so only its kind is checked.

A record literal is checked too when a field's value is only known at run time. -> Note returning { id = id } with an untyped id that holds 1 is refused, naming the field, rather than building a Note whose string field holds an int.

record Entry { id: string, amount: int = 0 }
record State { gold: int, ledger: { Entry } = [] }
fn sys(s: State) -> int return s.gold + 1 end

let bad: any = { gold = "lots", ledger = [] }
try print(sys(bad)) rescue e print(e.message) end
let short: any = { gold = 1 }
try print(sys(short)) rescue e print(e.message) end
let s: State = { gold = 1 }        -- a literal is built: `ledger` gets its default
print(sys(s), len(s.ledger))
$ scua crossing.scua
parameter 's' of 'sys': s.gold is string, expected int
parameter 's' of 'sys': s.ledger is missing; use `parse(value, State)` to fill its default
2 0

A bare collection is checked the same way: fn sum_amounts(xs: { Entry }) refuses a string, and doesn't walk the entries. P? and { T }? get the same check whenever the value isn't nil.

record Entry { id: string, amount: int = 0 }
fn sum_amounts(xs: { Entry }) -> int
  let n = 0
  for e in xs do n = n + e.amount end
  return n
end
let v: any = "hello"
try print(sum_amounts(v)) rescue e print(e.message) end
print(sum_amounts([]))
$ scua bare.scua
parameter 'xs' of 'sum_amounts' is string, expected { Entry }
0

Because an annotation looks one level deep, passing a record costs the same whatever it holds. A game state with a 50,000-entry ledger costs as much to hand to a typed function as one with an empty ledger.

A scalar annotation on a binding is checked too: let n: int = x faults if x holds anything but an int. The test is left out only when the value is already known to be an int, such as a literal or arithmetic on checked ints. A value read out of a record's field is always tested, since the record was checked one level:

let s: any = "seven"
try
  let n: int = s
  print(n)
rescue e
  print(e.message)
end
$ scua scalar_let.scua
binding 'n' is string, expected int

A parameter with a scalar type is checked whenever the program calls the function, and a declared return type every time the function returns, for each value a -> (int, string) function returns. The rules are the checker's: an int satisfies float and number, int? also accepts nil, and a default fills a missing argument before the check. NaN is a float, so a float parameter accepts it. Falling off the end of a function with a declared return type returns nil and is checked like any other return, so -> int needs a return on every path, where -> int? does not.

fn half(x: int) -> int return x // 2 end
let v: any = 2.5
try print(half(v)) rescue e print(e.message) end
fn pair(a: any) -> (int, string) return 1, a end
try print(pair(2)) rescue e print(e.message) end
$ scua scalars.scua
parameter 'x' of 'half' is float, expected int
return value 2 of 'pair' is int, expected string

Caught with try/rescue or pcall, a rejected crossing is a record, like a where refinement's: e.message is the text above, e.path the path to the value that didn't fit (s.gold), e.at the same path from inside the value (gold), and e.value what arrived.

#parse checks and converts all the way down

parse(value, T) builds a real T from loose data: a new record for every record in it, defaults filled, any migrate hook run, every nested record and every element of a typed collection or map checked with its where rules, all the way down, including self-recursive types like a linked list or a tree. T can be any type you could write in an annotation: parse(v, Player), parse(v, { Item }), parse(v, shapes.Pt). sys.parse_args(Args) does the same with the command line. Data from outside your program, such as a save, a JSON body or host input, goes through parse once, where it arrives, and typed code trusts it from then on:

record Item   { id: string }
record Player { name: string, hp: number, inventory: { Item } = [] }

let incoming: any = { name = "Ana", hp = 80, inventory = [{ id = "bow" }] }
let ana = parse(incoming, Player)   -- checked all the way down here, trusted afterward
print(`recruited {ana.name} with {ana.hp} hp and {len(ana.inventory)} item`)

let bad: any = { name = "Bo", hp = 70, inventory = [{ id = "axe" }, { id = 5 }] }
try parse(bad, Player) rescue e print(e.message) end
$ scua boundary.scua
recruited Ana with 80 hp and 1 item
parse(bad, Player): bad.inventory[1].id is int, expected string

parse never changes the value you give it. Its result is a new value, so a change you make to the result does not reach the input: convert once where the data arrives, and keep working on the result. A const (or freezed) value that needs nothing converted comes back as the same value, at no cost.

A field typed decimal or money also takes its exact text, "1.5" or "19.99 USD", which is how json.encode writes them, so a value saved as JSON reads back as it was.

Keys the record doesn't declare are left behind by parse, on purpose: data written by a newer version of your program, or by another service, often carries fields this one doesn't know yet. For data that has to be exact, such as a hand-written config where a misspelt key would quietly use the default, pass { unknown = "refuse" }. It refuses a key no field names, at every level, and names the field it was probably meant to be:

record Pity   { hard: int = 90, soft: int = 70 }
record Banner { name: string, pity: Pity = {} }

let raw: any = { name = "Legends", pity = { hrad = 80 } }
print(parse(raw, Banner).pity.hard)                    -- `hrad` is left behind; `hard` keeps 90
try
  parse(raw, Banner, { unknown = "refuse" })
rescue e
  print(e.message)
  print(e.at)
end
$ scua strict.scua
90
parse(raw, Banner): raw.pity.hrad is not a field of Pity; did you mean hard?
pity.hrad

To use the same options in many places, name them once with a top-level const: const STRICT = { unknown = "refuse" }, then parse(raw, Banner, STRICT). The compiler reads the options, so they must be a table literal or a top-level const holding one.

A record with a migrate hook is the exception: its old data carries fields the hook renames, so it isn't checked for unknown keys.

A rejected parse is a fault, not an Error value, so it needs try … rescue (or pcall); the ? operator only unwraps Ok/Error.

#Decoding into a type

json.decode, durable.decode, toml.parse and ini.parse take the type as an optional second argument. With it, the result is Ok(T), checked and converted as parse would, and anything that doesn't fit is an Error holding the fault record, bad bytes included, so ? handles a bad save the way it handles a corrupt one and one Error(e) arm reads e.message for both. A decoder's own complaint about the bytes keeps its line and column where it gives them:

import json

record Line  { item: string, qty: int }
record Order { id: string, lines: { Line } }

fn load(text: string) -> Result
  let order = json.decode(text, Order)?
  return Ok(`order {order.id}: {len(order.lines)} lines`)
end

print(load("{\"id\": \"A1\", \"lines\": [{\"item\": \"tea\", \"qty\": 2}]}"))
match load("{\"id\": \"A2\", \"lines\": [{\"item\": \"tea\", \"qty\": true}]}")
  Ok(v) -> print(v)
  Error(e) -> print(e.message)
end
match load("{\"id\": ")
  Ok(v) -> print(v)
  Error(e) -> print(e.message, e.line, e.column)
end
$ scua orders.scua
Ok(order A1: 1 lines)
json.decode(text, Order): value.lines[0].qty is bool, expected int
json.decode(text, Order): invalid JSON at line 1, column 8 1 8

The options parse takes go after the type: json.decode(text, Config, { unknown = "refuse" }). The type is read where the call is written, so a decoder held in a variable (let d = json.decode) takes none. Without the type, a decoder gives plain data, which typed code checks only one level.

#What typed code trusts, and what it doesn't

Untyped code can still change a record after it was checked: a function with an untyped parameter can write x.items[0].id = 5. The next annotation doesn't look inside items, so the bad value is found where something reads it. A declared return type, a typed let, a typed parameter of the field's own type, or arithmetic catches it. Something that accepts any value, such as a template, .., print or ==, does not:

record Item { id: string }
record Bag  { items: { Item } = [], owner: string }
fn first_id(b: Bag) -> string return b.items[0].id end
fn first_label(b: Bag) return `item {b.items[0].id}` end
fn scribble(x) x.items[0].id = 5 end   -- untyped code writes the wrong kind

let bag = parse({ owner = "Ana", items = [{ id = "bow" }] }, Bag)
print(first_id(bag))
scribble(bag)
print(first_label(bag))
print(first_id(bag))
$ scua bag.scua
bow
item 5
scua: bag.scua:3: return value of 'first_id' is int, expected string
  at first_id (bag.scua:3)
  at main (bag.scua:11)

A record's own where rule runs at every annotation, over nested data the annotation didn't check, so write a where that reads only its own record's fields.

#Saving a checked value

To check a whole value all the way down before you save it, give durable.encode or json.encode the type: durable.encode(value, version, T), json.encode(value, T). Nothing is copied. If anything in the value doesn't fit, or has no saved form (a function left in a field typed any, say), you get an Error naming the path, and nothing is encoded:

import durable

record Player { name: string, rating: int where rating >= 0 }
record Save   { season: int, players: { Player } = [] }

fn save_bytes(s: Save) -> Result
  let blob = durable.encode(s, 2, Save)?
  return Ok(len(blob) > 0)
end
fn loosen(x) x.players[0].rating = -5 end

let s = parse({ season = 3, players = [{ name = "Ana", rating = 10 }] }, Save)
print(save_bytes(s))
loosen(s)
match save_bytes(s)
  Ok(v) -> print(v)
  Error(e) -> print(e.message)
end
$ scua save.scua
Ok(true)
durable.encode(s, 2, Save): s.players[0].rating violates Player's where rule (got -5)

Without the type, an encoder writes what it is given. Where a typed encode is written, the checker warns at each field of the type that could hold a value with no saved form (any, a collection of any, a function type). Save the player's progress shows a save that keeps the last good one when this happens.

#Literals become records

A record literal written where the record type is known is built as that record, with its defaults filled: a typed let, a return from a function declared -> Player (or -> { Player }), a field of that type, a record field's default, an element pushed into, inserted into or stored in a typed collection, and an argument to a typed parameter of a function in the same file. Untyped code can't add a key to it afterwards:

record Entry { id: string, amount: int = 1 }
record State { ledger: { Entry } = [] }
fn add_entry(s: State, id: string) push(s.ledger, { id = id }) end
fn tag(x) x.note = "loose" end

let s: State = {}
add_entry(s, "a")
print(s.ledger[0].amount)
let e: any = s.ledger[0]
try tag(e) rescue err print(err) end
$ scua ledger.scua
1
cannot add field `note` to Entry: a record has only the fields its declaration lists

The checker checks such a literal as the record it becomes: a required field left out, or a field the record doesn't have, is a compile error where the literal is written. A literal handed to a function through a variable, or to a method, is checked as it is and not built, so leaving out a field with a default is refused there; write parse({ players = 8 }, Settings) at the call.

#Checking everything while you test

Run with the deep mode to check every annotation all the way down, as parse does, without converting anything. It only adds faults, so a program that passes with it passes without it:

$ scua --check=deep bag.scua
bow
scua: bag.scua:4: parameter 'b' of 'first_label': b.items[0].id is int, expected string
  at first_label (bag.scua:4)
  at main (bag.scua:10)

#When the checker warns

A typed let or a return of a value typed any into a record that holds other records or collections checks only the outer record, which is rarely what loading code means. The checker says so:

import durable

record Player { name: string }
record Save   { season: int, players: { Player } = [] }
let v: any = durable.decode(durable.encode({ season = 1, players = [] }, 2))?
let s: Save = v
$ scua load.scua
scua: load.scua:6: warning: `v` is `any`, so `let s: Save = v` checks only Save's own fields; nested `players` is not checked. Use parse(v, Save) to check it all the way down.

Write durable.decode(blob, Save)? or parse(v, Save) instead, and the warning goes. A parameter never warns, since code that calls through function values passes any on purpose.

#What each operation promises

after it succeeds until
an annotation the value's kind; for a record, its own fields' kinds and its own where the next write to it by code that sees it as any
parse(value, T), a typed decoder a new T, checked at every level; the input as it was the next such write to the result
durable.encode(value, version, T), json.encode(value, T) the output holds a value that was a T at every level — (the output doesn't change)

Every promise is about the moment the check ran. Removing an annotation never adds a fault, and nor does turning the deep mode off.

#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 field `trophies` to Player: a record has only the fields its declaration lists

A plain untyped table, by contrast, grows as much as you like. The seal is a property of records, not of every table. A record keeps its seal when it is sent to an actor, too: it arrives as the same record type, so a handler that wants extra fields copies what it needs into a table of its own.

There is no fixed limit on how many fields a record can declare. A binding surface with one field per bound function is a record like any other, however wide it is. (The technical ceiling is 65535, the same as Java's, and the compiler tells you the count if you ever reach it.) A plain table with more than sixteen keys keeps working too. It switches to hash-map storage, which is why its iteration order stops being insertion order at that point.

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

Loading old data into a record is a conversion, and the conversion is parse(value, Record). A typed let on its own only checks a value; parse builds the record, and that is the step that upgrades old shapes.

Additive changes are automatic. Add a field with a default and old saves that lack it get the default when parse converts them. 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. parse runs it 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 = parse(v1save, Account)                   -- 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 — gated fields and project, for sending an audience-safe view.
  • Contracts — where refinements that constrain field values at the boundary.
  • Errors and faults — try/rescue and the sealed-record fault.