A contract is a named set of conditions over some
values. It's a predicate you can name, reuse, and compose. Call it and
you get back a report of which conditions held and which didn't. It
evaluates every clause, not just the first one that fails. That makes
contracts a good fit for gating logic ("can this entity sprint?") and
for player-facing checklists ("1 of 3 objectives done").
contract Adult(p) p.age >= 18 end
print(Adult({ age = 25 }).ok)
print(Adult({ age = 12 }).ok)
Run it:
$ scua adult.scua
true
false
A single-clause contract is just a named predicate. Call it and read
.ok for the boolean.
#Clauses and the report
Most contracts have several clauses, each with a label and an
optional else reason explaining what to fix:
contract LevelComplete(player, world)
gold: player.gold >= 100 else "collect 100 gold"
boss: world.boss_dead else "defeat the boss"
time: player.time <= world.par_time else "finish under par time"
end
Calling it returns a report. The report has ok (did
everything pass), done and total (how many of
how many), and clauses, a list with one entry per clause.
Each clause entry carries its name (the label),
ok, a why (the else reason), and,
when the clause is a comparison, the actual and
target values. That's enough to drive a "1 of 3 objectives"
UI directly:
contract LevelComplete(player, world)
gold: player.gold >= 100 else "collect 100 gold"
boss: world.boss_dead else "defeat the boss"
time: player.time <= world.par_time else "finish under par time"
end
fn show(player, world)
let r = LevelComplete(player, world)
print(`Level complete? {r.ok} ({r.done} of {r.total})`)
for c in r.clauses do
if c.ok then
print(` [x] {c.why}`)
else
if c.target == nil then
print(` [ ] {c.why}`)
else
print(` [ ] {c.why} ({c.actual}/{c.target})`)
end
end
end
end
show({ gold = 120, time = 83 }, { boss_dead = false, par_time = 60 })
$ scua objectives.scua
Level complete? false (1 of 3)
[x] collect 100 gold
[ ] defeat the boss
[ ] finish under par time (83/60)
A boolean clause like boss has no numeric progress, so
its actual and target are nil. A
comparison clause like time fills them in, here 83 against
a target of 60.
Contracts are meant to be pure: anything dynamic (the clock, a feature flag) is passed in as a parameter rather than read from the outside. That keeps a contract replayable.
#Composing contracts
A clause can call another contract and read its result. Build small predicates and combine them:
contract Adult(p) p.age >= 18 end
contract CanEnterTournament(p)
Adult(p).ok else "must be 18+"
p.rank >= 10 else "reach rank 10"
end
let r = CanEnterTournament({ age = 25, rank = 4 })
print(`eligible? {r.ok}`)
for c in r.clauses do
if not c.ok then print(` missing: {c.why}`) end
end
$ scua compose.scua
eligible? false
missing: reach rank 10
#The boolean shortcut:
matches
The full report allocates a list of clauses. On a hot path, where you
only need a yes or no, that's wasted work.
value matches Contract inlines the clauses to a plain
boolean with no allocation:
contract CanSprint(p)
p.stamina >= 20
p.grounded
end
const player = { stamina = 35, grounded = true }
if player matches CanSprint then
print("sprinting")
end
print({ stamina = 5, grounded = true } matches CanSprint)
$ scua sprint.scua
sprinting
false
The split is deliberate: call the contract directly when you want the
report to show a player what's left, use matches when you
just need to gate something per frame or per entity.
matches has one other meaning: r matches Ok
and r matches Error test a Result. Those two names are
reserved, so there is no contract to confuse them with; naming a
contract Ok or Error is a compile error.
A clause, like a where refinement, is a condition, so a
clause that produces a Result or a flags set is an error rather than
always true: write check(x) matches Ok, or
Flag.A in x.mask, in the clause instead.
x matches C is always true or
false, and every clause is tested, the last one
included.
#The in operator
in tests membership and reads naturally inside a clause
or anywhere else. x in [a, b, c] checks an array.
x in lo:hi checks a half-open range, the same
[lo, hi) span as a for loop or a slice, so
1:101 covers 1 through 100:
contract ValidHero(h)
cls: h.cls in ["mage", "rogue", "warrior"] else "pick a known class"
level: h.level in 1:101 else "level must be 1..100"
end
print(ValidHero({ cls = "mage", level = 42 }).ok)
print(ValidHero({ cls = "bard", level = 42 }).ok)
$ scua valid-hero.scua
true
false
Because the range is half-open, 100 in 1:100 is false;
write 1:101 if you mean to include 100.
#Refinements with where
A contract can be attached to a record or one of its fields as a
where refinement. It's a pure predicate, with the record's
field names in scope, checked whenever data crosses into that type: a
typed binding, a parameter whose type is the record, a persistence load.
Inside typed code it's erased.
You attach the refinement to a named record or type, not to a
parameter directly. There is no inline
fn f(n: int where Positive) form (it's a syntax error).
Give the parameter a type that carries the where, and the
check runs when an argument crosses in.
contract NonNeg(n) n >= 0 end
record Stat { value: int where NonNeg, cap: int } where value <= cap
let s: Stat = { value = 30, cap = 100 }
print(`stat {s.value}/{s.cap}`)
let loose = { value = -5, cap = 100 }
try
let bad: Stat = loose
rescue e
print(`rejected {e.record}.{e.field} = {e.value}`)
print(` {e.message}`)
end
$ scua stat.scua
stat 30/100
rejected Stat.value = -5
binding 'bad': bad.value violates Stat's where rule (got -5)
Here a bare contract name on a field
(value: int where NonNeg) is the non-allocating check, the
same as value matches NonNeg. A where clause
after the record body (where value <= cap) constrains
the record as a whole.
Either kind of where can say why, with
else "reason". The reason leads the fault's message, and
scua schema publishes it as the field's description, so
whoever fills the record in learns the rule:
record Tool { title: string where title.len() >= 1 else "a title is required" }
try
let t: Tool = { title = "" }
rescue e
print(e.message)
end
$ scua tool.scua
binding 't': t.title: a title is required (field 'title' of Tool, got )
Be clear about the guarantee. The refinement is checked at the boundary and then trusted. It is not re-checked on every later write, so a mutation afterward can push the value back out of range without anyone noticing. The promise is "valid when it crossed in," not "valid at every point in the program."
#Introspecting a boundary fault
When loose data violates a refinement at a boundary, you get a fault
you can catch and inspect. As the example above shows, the fault carries
e.record, e.field, and e.value
for precise triage, plus e.message for a ready-made
summary. See errors and faults for how
try/rescue works.
#See also
- Records and gradual types — the
types that
whererefinements attach to. - Pattern matching — the other place
match-style thinking shows up. - Errors and faults — catching and reading a refinement fault.