Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Error Handling

Errors in Graphix are represented by the Error<'a> type. A new instance of which can be created with the error function. e.g.

〉error(`Foo)
-: Error<'a>
error:`Foo

Catch and ?

While errors are normal values, and can be matched in select, they can also be thrown and handled like exceptions. A catch(e) expr statement installs an error handler covering the REST of its enclosing scope — every statement after it, and everything called from those statements. The ? operator throws errors generated by the expression on it’s left to the nearest installed catch in dynamic scope. for example,

〉let a = [1, 2, 3, 4]
〉catch(e: Error<ErrChain<`ArrayIndexError(string)>>) println(e)
〉a[15]?
-: i64
error:{cause: null, error: `ArrayIndexError("array index out of bounds"), ori: {parent: null, source: `Unspecified, text: "a[15]?\n"}, pos: {column: 1, line: 1}}

Catches the array index error and prints it’s full context to stdout. In the REPL each input line is checked on its own, so a catch entered before the code that raises to it has seen no error yet: annotate it with the errors it takes, as here. In a file the catch’s type is inferred from what it covers.

Catching errors is NOT control flow: the catch statement is an installation, its type is bottom (it never produces a value), and an error being raised does not stop the execution of any node. The handler is an ordinary reactive expression that runs when an error value arrives — the natural idiom is to connect it to state your program reads,

let error_display: [null, string] = null;
let x = {
    catch(e) error_display <- "[e]";
    calc(in0)
};
widget([x, text(error_display)])

The catch here covers calc(in0) — and only that block: a catch installed inside a block covers the rest of THAT block, nothing outside it. A second catch in the same block shadows the first for the statements below it; a handler may rethrow with ?, which resolves to the previous catch (or the next one out). Every error raised with ? is wrapped in an ErrChain struct, the full definition of which is,

type Pos = {
    line: i32,
    column: i32
};

type Source = [
    `File(string),
    `Netidx(string),
    `Vfs(string),
    `Program,
    `Unspecified
];

type Ori = {
    parent: [Ori, null],
    source: Source,
    text: string
};

type ErrChain<'a> = {
    cause: [ErrChain<'a>, null],
    error: 'a,
    ori: Ori,
    pos: Pos
}

This gives the full context of where the error happened, and whether it was previously caught and reraised, giving the full history back to the first time it was ever raised.

The scope is dynamic, not lexical, mirroring exception systems that unwind the stack,

〉let div0 = { catch(e) println(e ~ "never triggered"); |x| (x /? 0)? }
〉catch(e: Error<ErrChain<`ArithError(string)>>) println(e)
〉div0(0)
-: i64
error:{cause: null, error: `ArithError("attempt to divide by zero"), ori: {parent: null, source: `Unspecified, text: "let div0 = { catch(e) println(e ~ \"never triggered\"); |x| (x /? 0)? }\n"}, pos: {column: 59, line: 1}}

The catch surrounding the function call site, not the definition site, is the one triggered. Note the use of the checked division operator /? combined with ? to propagate the error – unchecked / would simply return bottom on division by zero rather than throwing.

Constraining the error type

The binder may carry a type annotation, catch(e: T) expr. That is a type ascription: e has type T in the handler. The compiler still checks that T contains every error the covered region can throw.

Checked Errors

Graphix function types are annotated by the type of error they might raise. In most cases this is automatic, but for some higher order functions it may be necessary to specify it explicitly. For example array map has type fn(a: Array<'a>, f: fn(x: 'a) -> 'b throws 'e) -> Array<'b> throws 'e indicating that while the map function itself does not throw any errors, it will throw any errors the function passed to it throws. This is all in the service of being able to statically check the type of thrown errors, for example,

let a = [0, 1, 2, 3];
catch(e) select (e.0).error {
    `ArithError(s) => println("arithmetic operation error [s]"),
    `ArrayIndexError(s) => println("array index error [s]")
};
(a[0]? +? a[1]?)?

There are two types of errors that can happen in this example: the array indexing can produce an ArrayIndexError, and the checked addition +? can produce an ArithError. The compiler knows both, and if you were to omit one of them, then the example would not compile. Suppose we remove the pattern for ArrayIndexError, we would get,

Error: in file test.gx

Caused by:
    0: at: line: 2, column: 1 in file test.gx, in: catch(e) select e.0.error { `ArithErro..
    1: at: line: 2, column: 10 in file test.gx, in: select e.0.error { `ArithError(s) => p..
    2: missing match cases: no arm covers `ArrayIndexError(string)

You’ll recognize that this is just the normal select exhaustiveness checking at work. Since errors are just normal types, the important point is the compiler knows the type of every error at compile time, everything else flows from there.

Unhandled Errors

By default when evaluating a file, the compiler will print a warning whenever an error raised by ? is not covered by an installed catch. Using -W flags you can change the compilers behavior in this respect.

The $ Operator, aka Or Never

The $ operator goes in the same position as ?, and is best described as “or never”. If the expression on it’s left is a non error, then $ doesn’t do anything, otherwise it logs the error at the warn! log level and returns nothing. This is a concise way of writing,

select might_fail(1, 2, 3) {
  error as _ => never(),
  v => v
}

can instead be written as,

might_fail(1, 2, 3)$

The $ operator logs errors rather than silently discarding them, making it easier to debug issues while still allowing execution to continue. The log line names the site and the error, and is the same whether the expression runs in the interpreter or in a JIT-compiled kernel.

? and $ on Nullable Values

Both operators also work on an optional value, a ['a, null]. $ is still “or never”: a null produces nothing, and because a null is not a failure nothing is logged. It is the way to say “wait until this is there”,

let session = (*connected)$

? raises. There is no error value to raise, so it makes one, `NullError(string), where the string is the expression that was null. Only a ? on a nullable adds that tag to the error type of the catch it raises to,

〉catch(e: Error<ErrChain<`NullError(string)>>) println((e.0).error)
〉let m = {"a" => 1}
〉let v = map::get(m, "b")
〉v?
-: i64
`NullError("v")

When a type has both an error and a null, the errors come off first, and the null is left in place. That is what you want from a function that returns Result<null, 'e>, its ? must not raise on success, and it means each operator takes one layer, as in Rust. To take both, write both,

db::txn::get(tree, key)?$

raises a database error to the nearest catch, and then waits for the key to have a value. Use opt::ok_or(v, e)? instead of a bare ? when the catch should hear something more specific than NullError.