Error handling in Tenet

Motivation

Thinking about failure is hard. Failure tends to explode multiplicatively, and it tends to obscure the desired algorithm.

It’s also difficult to agree on what things are errors. If we’re working with a dictionary, a key being missing could indicate an error, but a key being present could equally indicate an error.

If 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. In Java, the categories Error, Exception and RuntimeException impose technical distinctions, but even simpler schemes still establish hierarchies that make semantic distinctions.

Then add to this the fact that error handling code is rarely executed, so it’s the hardest code to refactor. It means that creating a good error hierarchy requires impossible foresight and planning.

Solution

Tenet’s approach is to sharply constrain when we make a determination that something is an error or exceptional. The test of a failure is one of control should never reach this point or the system should never be in this state . This is the motivation behind the never statement.

Everything else is considered an ordinary value, and rather than trying to read tea leaves to decide exactly what kind of error a value happens to be, we state what the value plainly is. We assume that plainly naming the thing will enable the caller to decide exactly what it represents and how to treat it.

Then, we allow a way for arbitrary values to “escape” computation until they’re caught at some point. This is the principal mechanism to allow the caller to organize control into the expected path and the exceptional path.

See also these future details.

Implementation

Escaping

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.

let result = some-func() ! ok   // If not ok~, the value escapes

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 would be an error.

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

Pattern matching

A direct way to handle a value that has multiple possible results is a when statement:

when risky-operation() {
    ok ~ let value -> {
        ... handle normal case ... 
    }
    #miss -> {
        ... handle missing case ...
    }
    #invalid-input -> {
        ... handle invalid input ...
    }
}

But a common idiom in programming is the notion of the “happy path.” We can make an algorithm clearer if we assume everything goes well, and then “catch” deviations as they arise.

Catching Escapes

So what the ! operator does is assert we got the desired value. If it’s not the desired kind of value, control escapes.

Escapes can be caught using a catch block that attaches to a preceding statement. Catch operates very similarly to a when statement:

let value = risky-operation() ! ok
    catch {
        #miss -> {
            ... handle missing case ...
        }
        #invalid-input -> {
            ... handle invalid input ...
        }
    }

However, like when, catch allows an abbreviated form for a single rule. And we can add any number of catch statements. If we put the subject in a do block, it looks like try / catch from other languages:

do {
    let value = risky-operation() ! ok
    ... do more work here ...
} catch #miss -> {
    ... handle missing case ...
} catch #invalid-input -> {
    ... handle invalid input ...
}

If no catch clause matches the escaped value, the escape continues propagating outward.

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:

fun work-on-risky-data() => good ~ Int | #miss {
    let value = risky-operation() ! ok
    return good ~ handle-value(value)
} catch #invalid-input -> {
    ... handle invalid input ...
}

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

And, catch statements can attach to the function body.

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.

let data = load-and-process-data()

when data.field {
    expected ~ let value -> { ... handle expected ... }
    #unusual -> { ... handle unusual ... }
    #should-have-been-eliminated -> { never }
}

Here, the logic of load-and-process-data should have eliminated the last value, so we know that control should never reach that branch of the when statement.

never, thus, immediately terminates the current function and signals a failure that cannot be caught by catch.

Distinguishing Failures vs. Escapes

Let’s recap the distinction between failures and escapes. We assert a failure only when one of the following is true:

For all other cases, our guidance is not to try to categorize various errors. The principle is to plainly and succinctly describe what the result is and not overthink where it fits into a grand hierarchy of errors.

There’s one more wrinkle in the current system because failures raised by never can’t be caught. Some applications may need to attempt recovery from such failures, so at this stage, libraries should return a #fatal value or the like, at which point the application can decide that it should never see that value returned.

A future version will allow libraries to use never in a way that can be managed by applications.

Future Directions

Future details

There are good technical reasons to have exception handling. In practice, handling all errors by returning values on the stack requires a significant amount of machine code to branch on all the results. Centralized error handling through exceptions can have significant performance benefits for certain platforms.

We’d like to allow library authors to not care about this, and allow applications developers to centralize exception handling based on the measured behavior of their application.

A further avenue of exploration is, once you’ve unified “normal” and “exceptional,” how do you represent all of it to a user? It suggests we want a general facility for localizing messages, and also selectively adding contextual information to returned values.