Error Handling Patterns
How to implement error handling, result types, and setjmp/longjmp in SBBE IR.
SBBE has no exception mechanism. Error handling is implemented by the frontend using return values, tagged unions, or extern calls to runtime support functions. This guide shows how to lower each pattern.
What we’re lowering
Consider a language with built-in error handling:
func divide(a: Int32, b: Int32) -> Result<Int32, Error> {
if b == 0 {
return .error(DivisionByZero)
}
return .ok(a / b)
}
func main() -> Int32 {
match divide(10, 0) {
case .ok(let value):
return value
case .error(let err):
return -1
}
}
The frontend must decide how to represent Result<T, E> and how call sites check for errors. There are four common strategies, ordered from simplest to most complex.
Strategy 1: Return codes
The simplest approach: functions return an integer where 0 means success and nonzero means error. The actual result (if any) is written through an output pointer.
Pseudo-code
func parseInt(s: *UInt8) -> Int32 {
let ch = s[0]
if ch < '0' || ch > '9' {
return -1 // error
}
// ... parsing logic ...
return 0 // success
}
Step-by-step lowering
- Return type: The function returns
i32. The caller checks if the return value is zero (success) or nonzero (error code). - Error path: When validation fails, push the error code and
ret. No memory allocation, no struct, no tag. This is the cheapest possible error representation. - Why return codes? Zero overhead on the success path (just a
retwith a value). The tradeoff is that the caller must check the return value at every call site, and there’s no way to carry rich error information without a separate output pointer.
SBBE lowering
func $parse_int(ptr) -> i32 {
// Returns 0 on success (result in a global), -1 on error
entry:
ldl 0
ldm.u8 // load first byte
dup
// Check if it's a digit (0x30-0x39)
ldi 0x30
lt.u i32
jmp.if error
// ... parsing logic ...
ldi 0 // success
ret
error:
drop
ldi -1 // error code
ret
}
Strategy 2: Multiple return values
SBBE functions can return more than one value. This lets the frontend return both a status flag and a result value directly on the stack, without needing an output pointer or memory allocation.
Pseudo-code
func divide(a: Int32, b: Int32) -> (Int32, Int32) {
if b == 0 {
return (1, 0) // (error_code, unused)
}
return (0, a / b) // (success, result)
}
Step-by-step lowering
- Return type: The function returns
(i32, i32). The first value is a status flag (0 = success, nonzero = error), the second is the result or error payload. - Success path: Push 0 (success), then push the computed result.
retpops both values and pushes them onto the caller’s stack. - Error path: Push the error code, then push a dummy value (0). Both are returned.
- Caller: After the call, both values are on the stack. The caller pops the result first (it’s on top), saves it, then checks the status flag.
- Why multi-return? No memory allocation, no output pointer, no struct layout. The values travel entirely on the operand stack. This is the cheapest way to return structured error information. The tradeoff compared to a plain return code is one extra stack slot per call.
SBBE lowering
func $divide(i32, i32) -> (i32, i32) {
// Returns (status, value) where status 0 = ok, 1 = error
entry:
ldl 1
eqz i32
jmp.if div_by_zero
// Success: return (0, a / b)
ldi 0 // status = success
ldl 0
ldl 1
div.s i32 // a / b
ret
div_by_zero:
// Error: return (1, 0)
ldi 1 // status = error
ldi 0 // dummy value
ret
}
Checking the result
match divide(10, 0) {
case (0, let value): return value
case (_, _): return -1
}
The caller receives both values on the stack (status deepest, result on top):
func $main() -> i32 {
var $value i32
entry:
ldi 10
ldi 0
call $divide // stack: [status, value]
str $value // save the value, stack: [status]
// Check status
eqz i32
jmp.if ok
jmp err
ok:
ld $value
ret
err:
ldi -1
ret
}
Strategy 3: Tagged result types (in memory)
For richer error handling where the error payload is larger than a few stack values, return a tagged union (see the unions guide) through an output pointer. Tag 0 = success with a value, tag 1 = error with an error code or message.
Pseudo-code
func divide(a: Int32, b: Int32) -> Result<Int32, Int32> {
if b == 0 {
return .error(1) // error code 1 = division by zero
}
return .ok(a / b)
}
Step-by-step lowering
- Result layout: The
Resultstruct is 8 bytes: a tag (i32at offset 0) and a payload (i32at offset 4). Tag 0 meansOk, tag 1 meansErr. - Output pointer: The caller passes a pointer to a pre-allocated result slot. The function writes the tag and payload into it. This approach is needed when the error payload is too large to return on the stack (e.g., error messages, nested error chains).
- Success path: Write
tag = 0at offset 0, then computea / band write it at offset 4. - Error path: Write
tag = 1at offset 0, then write the error code at offset 4. - Return value: The function also returns an
i32(0 or 1) so the caller can branch without loading the tag from memory. This is an optimization; the caller could also load the tag directly. - Why tagged results in memory? They carry arbitrarily complex error information (strings, nested types) in a uniform structure. The cost is memory allocation and pointer indirection. For simple cases where the error is just an integer, prefer multi-return (Strategy 2) instead.
SBBE lowering
// Result layout:
// offset 0: i32 tag (0 = Ok, 1 = Err)
// offset 4: i32 value_or_error
func $divide(i32, i32, ptr) -> i32 {
// params: a=0, b=1, result_ptr=2
// Returns 0 on success, 1 on error. Writes to result_ptr.
entry:
ldl 1
eqz i32
jmp.if div_by_zero
// Success: write tag=0, value=a/b
ldl 2
ldi 0
stm i32 // tag = 0 (Ok)
ldl 2
ldi 4
add.s i64
ldl 0
ldl 1
div.s i32
stm i32 // value = a / b
ldi 0 // return success
ret
div_by_zero:
// Error: write tag=1, error_code=1
ldl 2
ldi 1
stm i32 // tag = 1 (Err)
ldl 2
ldi 4
add.s i64
ldi 1
stm i32 // error_code = 1 (division by zero)
ldi 1 // return error
ret
}
Checking the result
match divide(10, 0) {
case .ok(let value): return value
case .error(_): return -1
}
The caller allocates the result on the stack, calls the function, then branches on the tag:
func $main() -> i32 {
var $result ptr
entry:
// Allocate result struct (8 bytes)
ldi 8
salloc
str $result
// Call divide(10, 0, &result)
ldi 10
ldi 0
ld $result
call $divide
// Branch on return value (0 = ok, 1 = err)
eqz i32
jmp.if ok
jmp err
ok:
// Load the value from result.payload
ld $result
ldi 4
add.s i64
ldm i32
ret
err:
ldi -1
ret
}
Strategy 4: setjmp / longjmp (non-local return)
For languages with exceptions or setjmp/longjmp, the frontend can use extern functions to interface with the C runtime.
Pseudo-code
func trySomething() -> Int32 {
try {
// ... risky work ...
throw Error(code: 1)
} catch (let err) {
return err.code
}
}
Step-by-step lowering
setjmpsaves the current execution state (registers, stack pointer) into a buffer. It returns 0 on the initial call.longjmprestores the saved state and causessetjmpto return again, this time with a nonzero value (the error code passed tolongjmp).- Try block: After
setjmpreturns 0, execution enters the “try” body. If something goes wrong,longjmpunwinds back to thesetjmppoint. - Catch block:
setjmpreturns nonzero, and the error code is on the stack. - Why setjmp/longjmp? It is the only way to implement non-local returns (unwinding across multiple call frames) without compiler-generated unwind tables. The cost is saving/restoring register state on every
setjmp, and thelongjmppath is expensive. Most modern languages prefer result types for this reason.
SBBE lowering
extern func $setjmp(ptr) -> i32
extern func $longjmp(ptr, i32)
func $try_something(ptr) -> i32 {
// param 0: jmp_buf pointer
entry:
ldl 0
call $setjmp // returns 0 on first call, nonzero on longjmp
dup
eqz i32
jmp.if try_body
jmp catch
try_body:
drop // discard the 0 from setjmp
// ... do risky work ...
// If something goes wrong:
ldl 0
ldi 1 // error code
call $longjmp // jumps back to setjmp, which returns 1
ret // unreachable
catch:
// setjmp returned nonzero — an error occurred
// error code is on the stack
ret
}
Key takeaways
- Return codes are the simplest pattern and impose no overhead
- Multiple return values let the function return both a status and a result on the stack with no memory allocation
- Tagged result types in memory carry arbitrarily complex error information at the cost of pointer indirection
setjmp/longjmpare available via extern bindings for languages that need non-local returns- SBBE does not add hidden control flow paths; all error handling is explicit in the IR