Error Handling
Saule uses try / catch for unexpected runtime errors. For expected, recoverable failures — missing data, invalid input, parse errors — prefer nullable return types (-> T?) and let null safety carry the failure through the type system. A user-defined Result<T> class is trivial to write on top of classes and generics if you want richer error payloads.
Throwing Errors
Section titled “Throwing Errors”class Player health: integer = 100
fn damage(amount: integer) if amount < 0 then throw "Damage cannot be negative" end
self.health = self.health - amount endend
local p: Player = Player()p.damage(10)println(p.health) -- 90Try / Catch
Section titled “Try / Catch”class Player health: integer = 0
fn init(health: integer) self.health = health end
fn damage(amount: integer) if amount < 0 then throw "Damage cannot be negative" end
self.health = self.health - amount endend
try local p: Player = Player(100) p.damage(-10)catch e: string println("Caught: " .. e) -- Caught: Damage cannot be negativeendThe catch clause names the thrown value and its expected type. Inside the catch block, code runs as if the try block had returned normally.
Several catch clauses
Section titled “Several catch clauses”A try may carry more than one clause. They are tried in source order, and the first whose type matches the thrown value handles it; a value no clause accepts carries on to the enclosing try:
class ParseError detail: string = ""
fn init(detail: string) self.detail = detail endend
fn risky() throw ParseError("unexpected `}` on line 3")end
try risky()catch e: string println("message: " .. e)catch e: integer println("code: " .. e)catch e: ParseError println("parse: " .. e.detail) -- parse: unexpected `}` on line 3catch e: any println("something else")endEach clause’s binding is scoped to its own body, so the clauses are free to reuse a name. catch e: any matches everything, which is why it belongs last — a clause after it can never run, and the compiler says so:
try throw "boom"catch e: any println("anything")catch e: string -- ERROR: this `catch string` can never run println("never")endfinally
Section titled “finally”A finally block runs on every way out of the try: normal completion, a clause that handled a throw, a throw that no clause handled, and a return, break or continue from inside:
local path: string = Os.tmpname()
local out: File = Io.open(path, IoMode.Write)!out.write("port=8080")out.close()
fn parse(text: string) -> string local eq: integer?, _: integer? = String.find(text, "=") if eq == nil then throw "no `=` in `" .. text .. "`" end return String.sub(text, eq + 1)end
fn fallback() -> string return "80"end
fn readPort() -> string local f: File = Io.open(path, IoMode.Read)! try return parse(f.read("a") ?? "") catch e: string return fallback() finally f.close() -- runs on all three paths endend
println(readPort()) -- 8080Os.remove(path)When nothing handled the throw, the finally runs and then the value carries on outward — cleanup does not swallow the failure. A try needs at least one of catch and finally; with neither it protects nothing and runs nothing.
Nullable returns for expected failures
Section titled “Nullable returns for expected failures”class Player local name: string
fn init(name: string) self.name = name end
fn getName() -> string return self.name endend
class Repo local items: table<Player>
fn init(items: table<Player>) self.items = items end
fn findPlayer(id: integer) -> Player? if id < 0 or id > #self.items then return nil end return self.items[id] endend
local repo: Repo = Repo({Player("Arthur")})
local name: string = repo.findPlayer(5)?.getName() ?? "Unknown"println(name) -- Unknownprintln(repo.findPlayer(1)?.getName() ?? "Unknown") -- ArthurReserve try / catch for truly unexpected runtime errors — bad data from an external source, file I/O failures, contract violations. For everyday “this lookup might miss”, T? plus ?. / ?? keeps the failure mode visible in the type signature.