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:
- Should control ever reach this point?
- Reworded: Should the system ever be in this state?
- 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:
- the library developer is responsible for clearly naming all possible results
- the library caller describes the expected and exceptional paths of control
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:
- the
okoperator, which will returnfalse - a
whenstatement or when operator?that accepts the pattern - a
letstatement, if its declared type includes the value- that is,
letwill not infer escaping values
- that is,
- the function itself, if it can return the value
- future:
catchblocks
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.