SCUA splits failure into two kinds, and keeping them apart is most of the job.
Some failures are expected. A charge declines, input doesn't parse, a
lookup misses. These are values: a function hands back a
Result and the caller decides what to do. Other failures
are bugs. A divide by zero, an index off the end, a broken invariant.
These are faults: they unwind the stack until something recovers them,
or they stop the program.
The short version: return a Result for a failure the
caller should handle, raise a fault for a situation that means the code
is wrong.
#Results: failure as a value
A fallible function returns Ok(x) on success or
Error(e) on failure:
fn parse_amount(s)
if s < 0 then return Error("amount cannot be negative") end
return Ok(s)
end
The caller has to deal with both arms, which is the point. The
postfix ? makes the common path short: it unwraps an
Ok and keeps going, or short-circuits by returning the
Error to its own caller.
fn parse_amount(s)
if s < 0 then return Error("amount cannot be negative") end
return Ok(s)
end
fn charge(balance, amount)
let a = parse_amount(amount)? -- unwrap Ok, or return the Error to our caller
if a > balance then return Error("insufficient funds") end
return Ok(balance - a)
end
fn report(balance, amount)
match charge(balance, amount)
Ok(left) -> print(`charged {amount}, {left} left`)
Error(e) -> print(`declined: {e}`)
end
end
report(100, 30)
report(100, 200)
report(100, -5)
Run it:
$ scua charge.scua
charged 30, 70 left
declined: insufficient funds
declined: amount cannot be negative
? is how an error from parse_amount flows
out through charge without a manual check at every level.
At the top you consume the Result with match, handling
Ok and Error explicitly.
#Faults: failure as a bug
A fault is for the situation that shouldn't happen. You raise one
with error(...), and the runtime raises one for you on
things like a divide by zero. A fault unwinds until a
try/rescue catches it. The rescue
block binds the fault and runs, and the program carries on:
fn safe_div(a, b)
try
return Ok(a // b)
rescue err
return Error(`math error: {err}`)
end
end
match safe_div(84, 2)
Ok(x) -> print(`84 / 2 = {x}`)
Error(e) -> print(e)
end
match safe_div(1, 0)
Ok(x) -> print(x)
Error(e) -> print(e)
end
print("still running")
$ scua safe-div.scua
84 / 2 = 42
math error: divide by zero
still running
safe_div catches the divide-by-zero fault and turns it
into a Result, which is a reasonable thing to do at a
boundary where you'd rather hand the caller a value than crash. The
still running line confirms the program continued.
A fault that nothing rescues stops the program with a located message:
print("before")
let x = 1 // 0
print("after")
$ scua uncaught.scua
before
scua: uncaught.scua:2: divide by zero
after never prints. That's the right outcome for a real
bug: fail loudly at the point of the mistake rather than limp along with
bad state.
When the fault happens inside a chain of function calls, the report includes a backtrace so you can see how the program got there, innermost call first:
fn health_percent(current, max)
return current * 100 // max
end
fn show_bar(entity)
return health_percent(entity.hp, entity.max_hp)
end
let dead = { hp = 0, max_hp = 0 }
print(show_bar(dead))
$ scua bar.scua
scua: bar.scua:2: divide by zero
at health_percent (line 2)
at show_bar (line 5)
at main (line 8)
The first line is the fault and where it happened; each
at line below is a caller, up to the top-level
main. A fault that happens at the top level, not inside any
call, just shows the one located line.
#pcall: try/rescue as a
value
pcall runs a function and reports the outcome as a
Result instead of unwinding. It's the value-shaped form of
try/rescue: you get Ok(result) if
the function returned normally, or Error(message) if it
faulted.
fn risky(n)
if n == 0 then error("cannot be zero") end
return 100 // n
end
match pcall(fn() return risky(4) end)
Ok(v) -> print(`got {v}`)
Error(e) -> print(`failed: {e}`)
end
match pcall(fn() return risky(0) end)
Ok(v) -> print(`got {v}`)
Error(e) -> print(`failed: {e}`)
end
$ scua pcall.scua
got 25
failed: cannot be zero
Reach for pcall when you want to run something that
might fault and branch on the outcome inline, without wrapping it in a
named try block.
#Which one to use
Return a Result when failure is part of the function's
normal contract and the caller is expected to handle it: parsing,
validation, I/O, anything where "it didn't work" is a routine outcome.
The Error carries the reason, ? propagates it,
and match forces the caller to confront it.
Raise a fault when continuing would mean running on broken state: a
violated invariant, an impossible branch, a precondition the caller was
supposed to guarantee. Don't reach for
try/rescue as routine control flow. Use it at
the boundaries where you'd rather contain a fault than let it take down
the whole program, the way safe_div does.
A useful rule of thumb: if you'd write a comment saying "this can't
happen," that's a fault. If you'd write "the caller should check for
this," that's a Result.
#See also
- Pattern matching — consuming
Ok/Errorwithmatch. - Contracts — boundary refinements raise catchable, introspectable faults.
- Records and gradual types — the sealed-record write that faults at runtime.