Skip to content

Error Handling

StarLang uses raise and try for type-safe error handling. Functions raise errors with raise, and callers catch them with try, which returns a result<T>.

raise

Stops execution with an error message. If uncaught, the program halts and prints the error with file and line number.

fn divide(a:number, b:number):number {
    if (b == 0) {
        raise "division by zero"
    }
    return a / b
}

raise with Error Code

You can include a numeric error code before the message:

fn connect(host:string):string {
    raise 1001, "connection refused"
    return ""
}

The error code is accessible via .code on the result. When no code is provided, .code defaults to 0.

Syntax

raise "error message"
raise error_code, "error message"
  • First form: string-only, .code will be 0
  • Second form: integer code + string message

Unhandled Error Output

math.star:3: error: division by zero

try

Wraps a function call and catches any raise inside it. Returns a typed result<T> instead of crashing.

var res:result<number> = try divide(10, 0)
if (res.failed) {
    console.log(res.error)
}

Syntax

var result:result<T> = try function_call(args)

Where T is the return type of the function being called.

result<T>

try returns a result<T> with four fields:

Field Type Description
.failed bool true if the function raised an error
.code i32 Error code (0 on success or when no code provided)
.error string The error message (empty string on success)
.value T The return value (nil on failure)

Success Path

var res:result<number> = try divide(10, 2)
assert(res.failed == false, "should succeed")
assert(res.value == 5, "10/2 = 5")
assert(res.code == 0, "success code is 0")

Error Path

var res:result<number> = try divide(10, 0)
assert(res.failed == true, "should fail")
assert(res.error == "division by zero", "error message")

Error Code Path

fn coded_error():number {
    raise 1001, "custom error"
    return 0
}

var res:result<number> = try coded_error()
assert(res.failed == true, "should fail")
assert(res.code == 1001, "error code")
assert(res.error == "custom error", "error message")

String Results

fn greet(name:string):string {
    if (name == "") {
        raise "name cannot be empty"
    }
    return name
}

var ok:result<string> = try greet("star")
assert(ok.failed == false, "should succeed")
assert(ok.value == "star", "value matches")

var err:result<string> = try greet("")
assert(err.failed == true, "should fail")
assert(err.error == "name cannot be empty", "error message")

Nested Calls

raise propagates through nested function calls. try catches errors from any depth:

fn inner():number {
    raise "deep error"
    return 0
}

fn outer():number {
    var x:number = inner()
    return x
}

fn main():void {
    var res:result<number> = try outer()
    assert(res.failed == true, "caught deep error")
    assert(res.error == "deep error", "message propagated")
}

Built-in and Runtime Faults

raise is not the only thing try catches. Runtime faults (type errors, divide-by-zero, out-of-bounds) and errors raised by native/stdlib functions flow through the same result<T> machinery. Each carries a reserved code so you can branch on the kind of failure:

Code Source Catchable?
0 raise without a code yes
2 type error yes
3 divide by zero yes
4 index / bounds error yes
5 parse error (e.g. json.parse on invalid input) yes
10 opcode budget exceeded (guard) yes
11 heap limit exceeded (guard) yes
12 event storm (integrity guard) no — fatal
13 channel / queue deadlock (no task can ever unblock) yes
1000+ user-defined yes

Codes 1999 below 1000 are reserved for the runtime; use 1000 and above for your own error codes.

Native and stdlib errors

Native functions participate in the error model. A failing json.parse, for example, behaves exactly like a raise with code 5:

var r:result<dict> = try parseBad()   # parseBad calls json.parse(invalid)
assert(r.failed == true, "invalid json should fault")
assert(r.code == 5, "parse error code")

Called without try, the same parse failure halts the program:

error: json.parse: invalid JSON

Recoverable vs fatal

The rule is: language/runtime faults are catchable; VM-integrity faults are fatal.

  • Recoverable (catchable via try, fatal only if uncaught): user raise, type/divide/bounds faults, parse errors, and the opcode-budget (10) and heap-limit (11) guards.
  • Always fatal (cannot be caught — they signal corruption or an integrity breach): bad bytecode, unknown opcode, stack over/underflow, and the event storm guard (12). A runaway emit loop terminates the program so a faulty handler cannot be silently swallowed by try.

Catching a guard fault is not the same as recovering

Guards trip on a condition that persists until you change it. The opcode budget resets on trap, so it recovers cleanly. The heap limit does not reset: it re-checks live memory on the next instruction. A heap-limit fault is only recoverable if the offending allocations become unreachable when the faulting function unwinds — then the post-trap GC reclaims them and the program continues. If the over-limit memory is still rooted (e.g. held by a global) after the catch, the very next instruction trips the guard again, fatally this time.

Design

  • No try/catch blocks — try wraps a single function call
  • No finally — cleanup is explicit
  • Type-safe: result<T> preserves the inner return type
  • Error codes: optional integer codes for structured error handling
  • No stack unwinding overhead — frame-based propagation
  • Unhandled raise halts the program, just like an unhandled panic
  • GC-managed: result objects are tracked by the garbage collector