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.
#Testing a Result
When you only need to know which one you got, test it with
matches: r matches Ok is true for
a success and r matches Error for a failure. Both are plain
booleans and work on any value, so nil matches Ok is just
false.
fn check_pin(pin)
if pin == "1234" then return Ok("unlocked") end
return Error("wrong pin")
end
let r = check_pin("0000")
if r matches Ok then print("in") end
if r matches Error then print("refused") end
match r
Ok(msg) -> print(msg)
Error(why) -> print(`no: {why}`)
end
$ scua pin.scua
refused
no: wrong pin
What you can't do is use the Result itself as a condition. An
Error is a value, not false, so
if r then would take the then branch for a
failure too: a PIN check written that way lets every PIN in. SCUA stops
instead, and tells you what to write:
fn check_pin(pin)
if pin == "1234" then return Ok("unlocked") end
return Error("wrong pin")
end
let r = check_pin("0000")
if r then print("in") end
$ scua refused.scua
scua: refused.scua:7: type error: a condition got a Result (`check_pin` always returns Ok or Error): an Ok/Error is never true or false. Handle both cases:
match r Ok(v) -> … Error(e) -> … end
Or test it: `r matches Ok` for success, `r matches Error` for failure.
The same goes for every place that asks a yes/no question:
while, elseif, an
if … then … else … end expression, not, the
left side of and and or, a match
guard, a where refinement, a contract clause and
assert. When the checker can see that a value is a Result,
you get the error before the program runs. It can see a call to a
function that returns Ok or Error, whether
check_pin is in the same file or comes from a module you
import (if pins.check_pin("0000") then), and a variable
that holds what such a call gave back, as r does here. A
Result it can't see coming, such as one handed to a function through a
parameter with no type, or read back out of a table field, is stopped
when the condition runs instead, with a message that says the same
thing. Inside a function the message also offers
let v = r?, which hands an Error on to your
caller.
One thing that looks like a test is not one. r != nil is
always true, because an Error isn't nil, so
comparing a Result with nil is a compile error, and the
message names the test you meant. So is comparing one with a plain value
such as r == 1, which is always false.
#A default with ??
?? gives you a default when there is no value. On a
Result, there is no value when it's an Error, or an
Ok with nil inside; an Ok with
anything else gives you what it holds. On a value that isn't a Result,
?? works as it always has: the default replaces
nil, and anything else passes through. The default is only
worked out when it's used.
import json
let port = json.decode("{\"port\": 8080}")?.port ?? 80
let cfg = json.decode("not json") ?? { port = 80 }
print(port, cfg.port)
$ scua port-default.scua
8080 80
The first line takes the value out with ?, reads
port, and would use 80 if the field were
missing. The second gets an Error, so cfg is
the default. That makes r? ?? d and r ?? d
agree, and when the default isn't nil, neither is the
answer. Use ?? where a missing value has a sensible
stand-in. Where the failure itself matters, match the
Result instead, so its reason isn't thrown away.
Ok and Error are reserved for results, so
you can't name your own contract, record, type alias, interface, enum
variant, flags member, message or partition handler Ok or
Error. The compiler says, for example, "Error
is reserved for results — rename this record".
#The checker makes you handle a Result
A Result is a wrapper around the value you want, and the checker will not let you use the wrapper as if it were the value. These are compile errors, each with the fix in the message:
- passing a Result where its value is needed: to a function that wants
bytes or a string, to an operator like
+or<, to a field or method (resp.status), or tofor … in; - putting a Result into text: a template hole,
..,format, or a builder'sadd.print, a log line andtostringstill show a Result as it is, because they show a value to a person; - giving a Result to
sys.emit, which would sendError(…)as your program's answer; - a
matchthat hasOk(…)orError(…)arms but doesn't cover both. An arm with awhenguard, or one with a literal inside such asError("timeout"), covers only what it says, so addError(e) -> …or_ -> …. When the checker knows the value inside anOkis a bool, as it does forfs.paths_alias, anOk(true)arm and anOk(false)arm together cover it. A bareOk ->arm is refused: it matches every value, anErrortoo.
A function that can return an Error, because it uses
? or because one of its returns gives an
Ok or Error, has to return Ok or
Error on every path. A plain return 5 in it is
a compile error that tells you to write return Ok(5), and
so is return f()?, which takes the value out of a Result
you could pass on as it is (return f()). A function that
uses ? and runs off its end, or reaches a bare
return, gives Ok(nil): it got through
everything that could fail, and had nothing more to say. A function that
returns an Error without using ? must not be
able to run off its end, because there running off the end gives
nil, which is neither: end it with
return Ok(nil). Declaring such a function
-> int is refused too: declare it
-> Result, or leave the return type off.
fn save(name)
if name == "" then return Error("no name") end
print(`saved {name}`)
return Ok(nil)
end
fn save_all(names)
for n in names do
save(n)?
end
end
print(save_all(["a", "b"]))
print(save_all(["a", "", "c"]))
$ scua save-all.scua
saved a
saved b
Ok(nil)
saved a
Error(no name)
Changing what a function returns changes what its callers get, so the
message names the callers, in the same file and in the package's other
files, and they change in the same edit. The common case is a helper
that returns an Error, or nil when all is
well, and whose callers write
if bad != nil then return bad end. It now gives
Ok(nil), and each caller writes check(x)?
instead of the nil test. Changing only the helper would be
worse than changing nothing: an Ok(nil) is not
nil, so every != nil test would treat success
as a failure. Comparing a Result with nil is an error for
that reason, and so is a test's assert_eq(check(x), nil),
which becomes assert_eq(check(x), Ok(nil)).
Some functions are meant to return a mix: a callback whose documented
answer is true, false, nil or a
Result, say, or a fake transport in a test that answers with a wrong
value on purpose. Declare such a function -> any (a
function literal too: fn(x) -> any … end) and the rule
leaves it alone: it may return anything, ? still passes an
Error on, and nothing is checked when it returns. Its
callers get a value the checker knows nothing about, so they are not
checked against it either; handle what it gives with match
where it matters.
import json
fn port_of(text)
let cfg = json.decode(text)?
return Ok(cfg.port) -- `return cfg.port` would be refused
end
match port_of("{\"port\": 8080}")
Ok(p) -> print(`listening on {p}`)
Error(e) -> print(`bad config: {e}`)
end
$ scua port.scua
listening on 8080
Calling a function that returns a Result and doing nothing with what
it gives back is a warning, not an error: the program runs, but a
failure there goes unnoticed. The warning names the call and how to
handle it. When a failure there really doesn't matter, say so with
_ (below). Storing a Result in a table field is a warning
too, since whatever reads the field later gets the wrapper; when the
field is meant to hold one, put it in a name declared any
first, let outcome: any = run(job), and store that.
The checker follows a Result into the functions you call: passing one
to a parameter with no type that the function then reads a field of,
does arithmetic on or puts into text is the same error as doing that
yourself, reported at your call, however many calls down the use is. A
function that tests the parameter itself first, with
type(p), matches, match,
? or ??, directly or in a helper it hands the
parameter to, is expected to get one, and nothing is reported. A test
that passes a Result on purpose, to check that a function refuses it,
declares the variable any:
let wrapped: any = load(). A Result kept in an array or a
table field, or handed to a function that only passes it on, stops being
one the checker can see, and the run time catches its misuse where it
happens.
Putting such a Result into text is caught too. Wherever a value
becomes text that goes on as data (a template, ..,
format, a builder's add,
str.join, render, to_csv), a
Result inside it, however deep, stops the program at that line with a
fault you can catch, for an Ok as well as an
Error. So the first test of the path that works finds the
place, rather than a stored Error(…) turning up later.
print, log lines and tostring show a Result in
full, so tostring(r) is how you put one into text on
purpose. json.encode writes one as
{"kind": …, "value": …}, and sys.emit refuses
one, since it would become your program's answer. The message never
shows what an Ok holds, as with every fault message (see --debug-values).
const outcomes = [Ok(3), Error("no such file")]
for r in outcomes do
match pcall(fn() return `outcome: {r}` end)
Ok(text) -> print(text)
Error(e) -> print(`refused: {e}`)
end
print(`on purpose: {tostring(r)}`)
end
$ scua outcomes.scua
refused: this template got an Ok where it needs text. Take the value out first with `?` or `match`.
on purpose: Ok(3)
refused: this template got an Error (no such file) where it needs text. Take the value out first with `?` or `match`.
on purpose: Error(no such file)
The code inside a partition's on and ask
handlers is checked the same way. A handler is not a function that must
return Ok or Error: there ? and
return Error(e) end the turn (an ask handler's
caller gets the Error as its reply), a return
is the reply, and the end of the handler needs no
return.
#Throwing a value away with
_
_ = f() runs f() and throws away what it
gives back, on purpose, so a dropped Result gets no warning.
let _ = f() means the same, and also works on releases
before this one, so code that has to run on both can use it.
_ is never a variable: in let _,
_ =, for _, v in …, a _ parameter
or a _ pattern it binds nothing, and it can appear more
than once.
fn save(name)
if name == "" then return Error("no name") end
return Ok(nil)
end
_ = save("draft")
for _, v in ["a", "b"] do print(v) end
$ scua discard.scua
a
b
Because nothing is kept, there is nothing to read back:
let _ = 5
print(_)
$ scua read-discard.scua
scua: read-discard.scua:2: `_` throws a value away, so there is nothing to read. Give it a name: `let total = …`.
A field named _, as in t._, is an ordinary
field.
#? and
return Error at the top level
At the top level of the script you run, ? on an
Error, or return Error(e), stops the script
exactly as sys.fail(e) does:
{"error": …} on stderr, with the Error's value
as JSON, and exit status 1. Whatever was already printed stays printed.
A return Ok(v), or any other value, exits 0 as before.
import json
print("reading config")
let cfg = json.decode("{\"port\": 8080")?
print(`listening on {cfg.port}`)
$ scua top-level.scua
reading config
{"error":"invalid JSON at line 1, column 14"}
Elsewhere:
- A module you import can't do either: its top level
is how you get its exports, and those are never a Result.
?or areturnthe checker can see is a Result is a compile error there, and a module whose top level gives anOkor anErroranyway makes theimportfault, naming the module. - Under
scua test, a test file whose top level stops on anErroris reported as failing (the top level returned Error: …), none of its tests run, and the other files still do. - In a sandbox, a load turn that stops this way gives
the host
Error({ kind = "failed", message, value, line }), wherevalueis theError's own value.
Beware a loop at the top level that serves many requests, such as
while true do let c = net.accept(l)? … end: the first
Error any one peer causes stops the whole server. Inside a
serving loop, match what each request gives.
#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
const dead = { hp = 0, max_hp = 0 }
print(show_bar(dead))
$ scua bar.scua
scua: bar.scua:2: divide by zero
at health_percent (bar.scua:2)
at show_bar (bar.scua:5)
at main (bar.scua:8)
The first line is the fault and where it happened; each
at line below is a caller, up to the top-level
main. Every line names its own file, so when a fault
happens inside a module you imported, the trace says so instead of
pointing at the file you ran. A fault that happens at the top level, not
inside any call, just shows the one located line.
A rescue block that raises the fault it caught again,
with error(e), passes it on unchanged: if nothing else
catches it, the report and the backtrace still show where it first
happened, not the line that re-raised it. That's true whether the block
calls error(e) itself or hands e to a function
that does. Raising a new value, like
error(`saving failed: {e}`), is a new fault, reported where
you raised it.
#Faults a running program survives
A fault nothing catches in your top-level code stops the program. Three places stop less than that, because they are where a long-running program does its work, and one bad input there shouldn't take everything else down:
- An
http.servehandler. The request that faulted gets a 500, and the server goes on answering. - A
spawned task. The task stops. Other tasks, partitions and a running server carry on. See When a spawned task faults. - A partition's handler. That turn stops, and the partition goes on to its next message. What the handler already changed stays changed. See When a handler faults.
Each one is reported on stderr when it happens, with its location, so someone watching a running program sees it then and not only when the program exits. A server's line names the request:
scua: app.scua:12: http.serve GET /orders/7 answered 500: fields only exist on tables and records, not on nil values
Two things that aren't faults are reported the same way, because in
each the Error reaches nobody else: a server handler that
returned an Error
(http.serve GET /orders/7 answered 500: the handler returned Error(no such order)),
and a partition handler whose turn ended with one (see When a
handler returns an Error).
A fault message names the value that caused it where that helps, as
in match: no arm matched (got 99). It never shows what an
Ok holds, which could be a decrypted secret: it says
got an Ok instead, and inside a table or list
Ok(…). An Error keeps its message. Under
scua test and the debugger the full value is shown, and on
an ordinary run --debug-values
shows it, for the times you can't reproduce a fault under the
debugger.
The client gets a bare 500 either way. The fault text goes to your
stderr, not to whoever sent the request, unless you pass --serve-debug
while developing. Characters in the path that could fake or recolour a
log line, such as a newline or an escape code, are written as
\x0a, \x1b and so on.
Whoever sends the bad requests decides how often these happen, so the reports are rate-limited: up to 20 are written at once, then one a second. Any held back are counted, and the next report that is written says how many:
scua: 37 more fault reports were not written (the log takes 20 at once, then one a second)
Returned Errors have a limit of their own, with their
own count
(scua: 280 more reports of returned Errors were not written …),
so a flood of bad messages that each end a turn with an
Error can't hide a real fault behind them. Each limit also
says at exit how many it held back in all.
A program in which a task or a partition faulted still exits with status 5 when it ends, the same as for a fault nothing caught, so a script or supervisor running it sees the failure. (A server's 500s don't change its exit status: they were answered.)
#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.
pcall takes any function value with no arguments, a
built-in or module function included: pcall(sys.readline)
is Ok of the line, or Error of the fault.
#Running out of stack
Every function call the program is inside costs a call
frame, and there is a ceiling: 4000 frames. Go past it
and you get a fault —
stack overflow: call depth exceeded 4000 frames — catchable with try/rescueorpcall``
— which behaves like every other fault. It unwinds,
try/rescue and pcall catch it,
and an uncaught one stops the program with a line and a backtrace.
Nine times in ten, hitting it means you wrote accidental infinite
recursion: a function that calls itself without a base case, or two
functions that call each other in a loop. Read the backtrace before you
reach for rescue — the fix is usually the missing base
case, not a handler.
The tenth time is real, and it's why the limit is catchable. If you write a library that walks data someone else supplies — a JSON document, a config tree, a parsed markup tree — you cannot know how deep it nests, and a document a few hundred levels deep is not your bug. Catch it at your boundary and hand the caller a value:
fn depth_of(node)
if type(node) != "array" then return 0 end
let best = 0
for child in node do
let d = depth_of(child)
if d > best then best = d end
end
return 1 + best
end
fn measure(doc)
try
return Ok(depth_of(doc))
rescue err
return Error(`document nests too deeply: {err}`)
end
end
fn nest(n)
let acc: any = [1]
for i in 0:n do acc = [acc] end
return acc
end
match measure([1, [2, [3]]])
Ok(d) -> print(`depth {d}`)
Error(e) -> print(e)
end
match measure(nest(5000))
Ok(d) -> print(`depth {d}`)
Error(e) -> print(e)
end
print("the program keeps running")
$ scua deep.scua
depth 3
document nests too deeply: stack overflow: call depth exceeded 4000 frames — catchable with `try`/`rescue` or `pcall`
the program keeps running
That is the pattern: measure promises a
Result, so "too deep" joins "malformed" and "empty" as one
of the failures it reports rather than a way for it to kill its caller.
Note that the frames are gone by the time your rescue block
runs — unwinding is what freed them — so the handler has a full stack to
work with, and returning a value from it is completely ordinary.
If you genuinely need to walk unbounded input, rewrite the recursion as a loop with your own stack. The catchable limit means you don't have to in order to be safe; it means you find out instead of dying.
#Values that get too big
One value — one array, one table, one string, one bytes
— has a ceiling of 256 MiB. For an array of mixed
values that is about 16.7 million elements. An array of
numbers stores them unboxed in a block of its own, and can hold up to
4,294,967,295 of them, memory permitting. The ceiling is structural, not
a budget: there is no flag that raises it.
Going over it is a fault, so it behaves like every other fault — it
names the limit, try/rescue and
pcall catch it, and an uncaught one stops the program with
a line and a backtrace.
fn make_buffer(n)
let buf = []
try
buf.reserve(n)
return Ok(buf)
rescue err
return Error(`cannot hold {n} items: {err}`)
end
end
match make_buffer(5000000000)
Ok(b) -> print("reserved")
Error(e) -> print(e)
end
$ scua buffer.scua
cannot hold 5000000000 items: reserve(5000000000) is over the largest an array may be (4294967295 elements)
The value you were building is untouched — the size is checked before anything is allocated, so there is no half-built array to clean up and you can carry on using it.
Most programs never come near this. If yours does, the answer is
usually to stop holding the whole thing at once: write results out as
you produce them, split the data across several values, or keep numbers
in a typed array. builder() is the right way to accumulate
text, but it does not lift the ceiling — it makes reaching it
cheaper.
#What you can't catch
Two failures deliberately ignore try/rescue
and pcall, because catching them would let a script escape
a limit it was given:
used its whole CPU budget— the--max-opsreduction budget, when you (or a host, or a sandbox) set one. A handler would resume with the budget still exhausted and immediately trip again, so a script could loop forever catching its own kill switch. Without--max-opsthere is no CPU limit to catch: the built-in ceiling refills rather than stopping the run, so an ordinary long computation just finishes.memory limit exceeded— the--max-memcap. Unwinding frees call frames, not the data that breached the cap, so a handler would run with nowhere to allocate.
The rule underneath is worth knowing, because it tells you which way a future limit will go: a limit is catchable when the handler runs in a state that no longer breaks it. Unwinding pops call frames, so the depth limit is catchable; nothing is allocated when a value is refused for being too big, so that one is too. Unwinding gives back no CPU budget and frees no memory, so those two are not — a handler would resume with the limit still breached and trip again on its next step.
print("printed before the budget ran out")
let n = 0
match pcall(fn() while true do n = n + 1 end return n end)
Ok(v) -> print(v)
Error(e) -> print(`caught: {e}`)
end
$ scua --max-ops=100k budget.scua
printed before the budget ran out
scua: budget.scua:3: this turn used its whole CPU budget of 100000 steps, set by `--max-ops`. Raise it, or remove the budget with `--max-ops=none`
The pcall does not intervene and the program stops. What
it does do is print everything the script had already printed —
a run that fails still shows you its output, which is usually where the
clue is.
#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
--max-ops/--max-mem— the two limits no handler can catch.- Pattern matching — consuming
Ok/Errorwithmatch. - Contracts — boundary refinements raise catchable, introspectable faults.
- Records and gradual types — the sealed-record write that faults at runtime.