Error handling in Tenet

Motivation

Thinking about failure is hard: the possible failure scenarios tend to explode multiplicatively, and trying to handle them tends to obscure the desired algorithm.

It’s also difficult to agree on what things are errors. If we’re writing a lookup table, a key being missing seems to naturally be an error, unless we’re using it to guarantee elements are unique, in which case being present indicates an error.

Even when it’s clear that a particular result ought to be some sort of error, error-handling schemes typically require that we further categorize the error as part of a pre-existing hierarchy.

Error handling code is rarely executed, hard to test, and winds up being the obscure code that users are wary of modifying.

Approach

Tenet’s approach is to view developers’ design of error handling as having two broad phases: initial and refinement. Initial shouldn’t be taken to mean thoughtless or instinctive, nor are all refinements necessarily beneficial. Rather, we’d like some clear doctrine that can be understood during both phases, and is naturally supported by the language.

That doctrine asks two questions:

  1. Should control ever reach this point?
    • Reworded: Should the system ever be in this state?
  2. If so, what few words clearly describe this result?

Then, if the answer to the first question is “no,” then this is a failure and the never statement will essentially crash the program.

The second question then treats the result as an ordinary value. Rather than trying to read tea leaves to decide exactly what kind of error a value happens to be, a library developer states what the value plainly is. Then the caller applies domain knowledge to decide exactly what it represents and how to treat it.

The caller selects the expected result using the ! operator, and if the unexpected result is transmitted, it escapes the expected control path. This is similar to how an exception is thrown; if it’s not locally caught and the function declares that it can return the escaping value, it can escape the function entirely.

So the separation of responsibilities is:

See also these future details.

Implementation

Escaping via the unpack operator !

The primary mechanism for signaling and handling abnormal results is called escaping.

When a tagged value is unpacked using the ! operator with a specific tag, if the actual tag does not match, the value escapes outward until it is caught by a surrounding context or reaches a function boundary.

fun escaping-value(arg: ok ~ Str | #nope): ok ~ Int | #nope | #fail {
    let add-a-zero = arg ! ok ++ "0"
    let to-num = str-to-int(add-a-zero) ! num
    return ok ~ to-num
}

fun try(arg: ok ~ Str | #nope): Unit {
    when escaping-value(arg) {
        ok ~ let x -> print-line("ok : " ++ int-to-str(x));
        #nope      -> print-line("nope :-(");
        #fail      -> print-line("wasn't a number :-(");    
    }
}

do {
  try(#nope);
  try(ok ~ "55");
  try(ok ~ "ten");
}

If an escaped value is not handled before exiting its containing function, it is a compile-time error.

This design intentionally leaves the question of “what is an error” up to the caller. If we look up a key in a dictionary, returning #miss is the intuitive error.

But if we’re loading that dictionary and expect our data to contain no duplicates, found ~ prior-value would be the error.

When operator ?

We looked at the when statement as a way to handle different results, but the ? operator, known as the when operator, operates very similarly:

fun try(arg: ok ~ Int | #nope | #fail): Unit {
    print-line(arg ? {
        ok ~ let x -> "ok : " ++ int-to-str(x);
        #nope      -> "nope :-(";
        #fail      -> "wasn't a number :-(";
    })
}

do {
  try(#nope);
  try(ok ~ 550);
  try(#fail);
}

The main difference in the semantics is the ? operator must return a value, while the when statement can perform actions.

Catching Escapes

So what the unpacking operator ! does is assert we got the desired value. If it’s not the desired kind of value, the value escapes control. For convenience, esc foo ~ "bar" behaves like (foo ~ bar) ! no-such-tag-exists.

Escaping values can be caught by:

Function Boundaries

Every function must handle (or explicitly allow to escape) all possible returned values from its body. That is, suppose we didn’t catch #miss in the above example:

type Foo = ();
fun work-on-data(might-miss: ok ~ Foo | #miss) => good ~ Int | #miss {
    let value = might-miss ! ok
    return good ~ handle-value(value)
}

Since the function itself is allowed to return #miss, the escaping value is returned normally.

If a value can escape out of a function without being caught, and the return type doesn’t explicitly include that variant, it is a compile-time error. In particular, if a function return type isn’t a union, it doesn’t allow escaping values.

The never Statement

The never statement asserts that control should “never” reach a specific point.

do {
    let mut i = 0;
    while i lt 10 {
        if i % 3 == 0 { i += 1; continue; }
        when i {
            6 -> { never }
            _ -> print-line("i = " ++ int-to-str(i));
        }
        i += 1;    
    }
}

Here, dropping every multiple of 3 should have eliminated 6, so we know that control should never reach that branch of the when statement.