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.
raise with Error Code
You can include a numeric error code before the message:
The error code is accessible via .code on the result. When no code is provided, .code defaults to 0.
Syntax
- First form: string-only,
.codewill be0 - Second form: integer code + string message
Unhandled Error Output
try
Wraps a function call and catches any raise inside it. Returns a typed result<T> instead of crashing.
Syntax
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 1–999 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:
Recoverable vs fatal
The rule is: language/runtime faults are catchable; VM-integrity faults are fatal.
- Recoverable (catchable via
try, fatal only if uncaught): userraise, 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 bytry.
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/catchblocks —trywraps 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
raisehalts the program, just like an unhandled panic - GC-managed: result objects are tracked by the garbage collector