SCUA

Manual

Errors and faults

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