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

  1. Return type: The function returns i32. The caller checks if the return value is zero (success) or nonzero (error code).
  2. 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.
  3. Why return codes? Zero overhead on the success path (just a ret with 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

  1. 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.
  2. Success path: Push 0 (success), then push the computed result. ret pops both values and pushes them onto the caller’s stack.
  3. Error path: Push the error code, then push a dummy value (0). Both are returned.
  4. 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.
  5. 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

  1. Result layout: The Result struct is 8 bytes: a tag (i32 at offset 0) and a payload (i32 at offset 4). Tag 0 means Ok, tag 1 means Err.
  2. 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).
  3. Success path: Write tag = 0 at offset 0, then compute a / b and write it at offset 4.
  4. Error path: Write tag = 1 at offset 0, then write the error code at offset 4.
  5. 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.
  6. 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

  1. setjmp saves the current execution state (registers, stack pointer) into a buffer. It returns 0 on the initial call.
  2. longjmp restores the saved state and causes setjmp to return again, this time with a nonzero value (the error code passed to longjmp).
  3. Try block: After setjmp returns 0, execution enters the “try” body. If something goes wrong, longjmp unwinds back to the setjmp point.
  4. Catch block: setjmp returns nonzero, and the error code is on the stack.
  5. 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 the longjmp path 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