Error Handling

GX has no exceptions. Errors are handled with return values — usually a bool that indicates success, combined with an out parameter for the result. This matches how C and Zig handle errors: explicit, visible, and zero-cost.

The Basic Pattern

Return bool for success/failure, write the result through out:

fn safe_divide:bool(a:f32, b:f32, out result:f32) {
    if (b == 0.0) {
        return false
    }
    *result = a / b
    return true
}

fn main() {
    var r:f32 = 0.0

    if (safe_divide(10.0, 3.0, &r)) {
        print("10 / 3 = {r}\n")
    }

    if (!safe_divide(5.0, 0.0, &r)) {
        print("error: division by zero\n")
    }
}

The caller must check the return value before using the result. Ignoring errors takes explicit effort.

Error Enum for Multiple Failure Modes

When there’s more than one way things can go wrong, return an enum:

enum ParseError {
    Ok
    EmptyInput
    InvalidChar
    Overflow
}

fn parse_digit:ParseError(ch:char, out value:i32) {
    if (ch == '0') { *value = 0; return ParseError.Ok }
    if (ch == '1') { *value = 1; return ParseError.Ok }
    if (ch == '2') { *value = 2; return ParseError.Ok }
    if (ch == '3') { *value = 3; return ParseError.Ok }
    if (ch == '4') { *value = 4; return ParseError.Ok }
    if (ch == '5') { *value = 5; return ParseError.Ok }
    if (ch == '6') { *value = 6; return ParseError.Ok }
    if (ch == '7') { *value = 7; return ParseError.Ok }
    if (ch == '8') { *value = 8; return ParseError.Ok }
    if (ch == '9') { *value = 9; return ParseError.Ok }
    return ParseError.InvalidChar
}

fn main() {
    var v:i32 = 0
    var err = parse_digit('7', &v)
    if (err == ParseError.Ok) {
        print("parsed: {v}\n")
    } else {
        print("parse failed\n")
    }
}

The caller checks against specific enum values to distinguish failure modes.

Result Struct Pattern

For richer error info, use a struct that holds both the value and the error state:

struct ParseResult {
    value:i32
    ok:bool
    message:str
}

fn parse_int:ParseResult(s:str) {
    var r:ParseResult
    r.value = 0
    r.ok = true
    r.message = ""

    if (s.len == 0) {
        r.ok = false
        r.message = "empty input"
        return r
    }

    // Simplified: assume single digit
    if (s.len == 1) {
        var ch = s[0]
        if (ch >= '0') {
            if (ch <= '9') {
                r.value = ch - '0'
                return r
            }
        }
    }

    r.ok = false
    r.message = "not a digit"
    return r
}

fn main() {
    var r = parse_int("7")
    if (r.ok) {
        print("value: {r.value}\n")
    } else {
        print("error: {r.message}\n")
    }
}

Early Return on Error

A common pattern is the “guard clause” — check errors at the top and return early:

fn process_user:bool(name:str, age:i32, out greeting:str) {
    // Guard: validate inputs
    if (name.len == 0) { return false }
    if (age < 0) { return false }
    if (age > 150) { return false }

    // All checks passed — do the actual work
    *greeting = "Hello, " + name
    return true
}

fn main() {
    var msg:str = ""

    if (process_user("Alice", 30, &msg)) {
        print("{msg}\n")
    }

    if (!process_user("", 25, &msg)) {
        print("error: invalid name\n")
    }

    if (!process_user("Bob", 200, &msg)) {
        print("error: invalid age\n")
    }
}

Defer for Cleanup on Error Paths

Combine defer with the error pattern so cleanup always runs, even on early return:

fn load_data:bool(out result:i32) {
    var arr:array<i32>
    arr.init()
    defer { arr.free() }   // runs on any exit

    arr.push(10)
    arr.push(20)
    arr.push(30)

    if (arr.len() == 0) {
        return false   // defer still runs
    }

    *result = arr.at(0) + arr.at(1) + arr.at(2)
    return true
}

fn main() {
    var sum:i32 = 0
    if (load_data(&sum)) {
        print("sum = {sum}\n")
    }
}

Checking Pointers for Null

GX uses 0 as the null pointer value. Check before dereferencing:

fn find_user:*i32(users:*i32, count:i32, target:i32) {
    // In GX, you can't return a pointer — this is just for illustration.
    // Real code uses out params.
    return 0
}

Since functions can’t return pointers, you typically check pointers passed in as parameters:

struct Node {
    value:i32
    next:*Node
}

fn print_list(head:*Node) {
    var cur = head
    while (cur != 0) {
        print("{cur.value} ")
        cur = cur.next
    }
    print("\n")
}

Panics for Unrecoverable Errors

For bugs that should never happen in correct code, use array bounds checks or explicit aborts. In debug builds (-O0), GX inserts bounds checks:

fn main() {
    var arr:i32[3] = {1, 2, 3}
    // var x = arr[10]   // panic in debug: "index 10 out of bounds (len 3)"
    print("done\n")
}

Panics terminate the program with a message and location — they’re for programmer errors, not user input errors. User input errors should use the return-value pattern.

Try it — Write a function safe_sqrt:bool(x:f32, out result:f32) that returns false for negative inputs. Test it in the Playground.


Expert Corner

Why no exceptions: Exceptions have two problems for systems programming:

  1. Hidden control flow: Any function call might throw. Every caller must consider cleanup for every line. The actual flow of execution isn’t visible in the source.
  2. Runtime overhead: Stack unwinding tables, try/catch blocks, possible heap allocations for exception objects. In C++ with -fno-exceptions or Rust with panic = "abort", the cost is real enough that games disable them.

GX’s return-value approach makes error paths explicit, zero-overhead, and visible at every call site. You can tell by reading the code which functions can fail.

Why bool + out param instead of a Result type: GX doesn’t have generic types (yet), so there’s no Result<T, E>. The equivalent is fn foo:bool(..., out result:T) — slightly more verbose but compiles to the same machine code as Rust’s Result::Ok branch. For multi-way errors, use a result struct or an error enum.

Compare to Go: Go’s idiom is value, err := foo() with multiple return values. GX’s idiom is if (foo(&value)) { ... }. Same information, different shape. Go puts the error last; GX puts the success bool as the return value. Go’s pattern relies on tuple returns, which GX doesn’t have — out params fill that gap.

Compare to Rust: Rust’s Result<T, E> + ? operator is more ergonomic but requires generics, pattern matching, and implicit propagation. GX takes the simpler path: manual checks, explicit error enums, no language features to learn. The tradeoff is more boilerplate on error paths, but complete visibility of control flow.

When to use panic vs return false: Use panic for:

  • Array bounds violations
  • Null pointer dereference
  • Invariant violations (“this should never happen”)
  • Programming bugs

Use return false for:

  • Failed parsing of user input
  • Network timeouts
  • File not found
  • Division by zero from runtime data
  • Any error that could happen in a correct program

The rule: panics are bugs, return values are recoverable errors. If the caller has a reasonable action to take, use a return value. If the only sane response is “stop the program and fix the code,” use panic.