The Tessera Book
Chapter 8

Errors

Programs fail. Files are missing, input is malformed, servers don't answer. Tessera separates two kinds of failure. Errors are things a correct program should expect and handle: they are values of type Result, returned like any other value. Panics are bugs: an index out of range, a division by zero. They stop the code that hit them. This chapter covers both, and defer, which runs cleanup however a block is left.

Result

Result[T, E] is an enum with two variants: Ok(T) with the value, and Err(E) with the error.

// A port number from text: Ok with the number, or Err saying what is wrong.
fn parse_port(s: Str) -> Result[Int, Str] {
    match s.parse_int() {
        None => Err("\"{s}\" is not a number"),
        Some(n) if n < 1 || n > 65535 => Err("{n} is out of range"),
        Some(n) => Ok(n),
    }
}

fn main(root: Root) {
    for s in ["8080", "http", "70000"] {
        match parse_port(s) {
            Ok(port) => root.stdout.print("port {port}"),
            Err(why) => root.stdout.print("bad port: {why}"),
        }
    }
    root.stdout.print("{parse_port("443")} {parse_port("0")}")
}
Listing 8-1: A function that can fail examples/ch08-01-result/main.tsr
$ tessera run main.tsr
port 8080
bad port: "http" is not a number
bad port: 70000 is out of range
Ok(443) Err("0 is out of range")

The type tells callers that parse_port can fail, and the compiler makes them deal with that, as with Option: a Result[Int, Str] is not an Int. Here the error is a Str explaining what was wrong; it can be any type.

Passing errors up with ?

Most code that gets an error can't do anything useful about it except pass it to its caller. The ? operator does that: on Ok it gives the value, on Err it returns the error from the current function.

fn main(root: Root) -> Result[(), IoError] {
    let n = count_lines(root.fs, "notes.txt")?
    root.stdout.print("notes.txt has {n} lines")
    Ok(())
}

fn count_lines(fs: Fs, path: Str) -> Result[Int, IoError] {
    let text = fs.read(path)?
    Ok(text.lines().len())
}
Listing 8-2: ? and an IoError from the file system examples/ch08-02-question/main.tsr
$ tessera run main.tsr
error: Denied("fs: not granted (run with --grant fs=DIR)")
$ tessera run main.tsr --grant fs=data:ro
notes.txt has 3 lines

Every capability operation that can fail returns Result[T, IoError]. IoError is an enum with two variants: Denied(why) when the capability doesn't allow the operation, and Failed(why) when it was allowed but didn't work, such as a missing file.

The first run shows what happens without --grant fs: root.fs exists but allows nothing, so fs.read returns Err(Denied(...)). count_lines passes it to main with ?, main returns it, and the program prints it and exits with status 1. The second run grants the data directory, read-only, and works.

main can return Result[(), E] for any error type. On Err, the program prints error: and the error, and exits with status 1.

? requires the error types to match. A function returning Result[_, IoError] can't pass up a Str error:

This program does not compile
fn parse_port(s: Str) -> Result[Int, Str] {
    match s.parse_int() {
        Some(n) => Ok(n),
        None => Err("not a number"),
    }
}

fn port_from_file(fs: Fs) -> Result[Int, IoError] {
    let text = fs.read("port.txt")?
    let port = parse_port(text)?
    Ok(port)
}

fn main(root: Root) {
    root.stdout.print("{port_from_file(root.fs)}")
}
Listing 8-3: Two kinds of error in one function examples/ch08-08-wrong-error/main.tsr
$ tessera check main.tsr
error[E0202]: `?` would return an error of type `Str`, but this function returns errors of type `IoError`
  --> main.tsr:10:16
    |
 10 |     let port = parse_port(text)?
    |                ^^^^^^^^^^^^^^^^^
  = note: convert the error first with `.map_error(|e| ...)`, or return `dyn Error`, which any error type with a `message` method converts to
  = help: return `dyn Error`, which any error converts to

1 error

You can convert by hand with .map_error(|e| ...). But the help suggests the usual answer, which is the next section.

Any error: dyn Error

Every program can use a built-in interface named Error:

interface Error {
    fn message(self) -> Str
    fn cause(self) -> Option[dyn Error]
}

A type satisfies it by having a message method; cause is optional. Str and IoError already do. dyn Error (interfaces and dyn are explained in chapter 10) is a value of any type that satisfies Error, and a function returning Result[T, dyn Error] can use ? on any Result whose error satisfies it. So one function can mix file errors, parse errors and errors of your own.

Your own error types just need a message method:

struct LineError {
    line: Int,
    text: Str,
}

fn message(self: LineError) -> Str {
    "line {self.line}: cannot read \"{self.text}\""
}

fn sum_lines(text: Str) -> Result[Int, LineError] {
    let mut total = 0
    for (i, line) in text.lines().with_index() {
        match line.parse_int() {
            Some(n) => {
                total = total + n
            },
            None => {
                return Err(LineError { line: i + 1, text: line })
            },
        }
    }
    Ok(total)
}

fn main(root: Root) -> Result[(), dyn Error] {
    root.stdout.print("{sum_lines("1\n2\n3")?}")
    root.stdout.print("{sum_lines("1\ntwo\n3")?}")
    Ok(())
}
Listing 8-4: An error type of your own examples/ch08-04-own-error/main.tsr
$ tessera run main.tsr
6
error: line 2: cannot read "two"

When main returns a dyn Error, the program prints its message().

Adding context

An error like fs: missing.conf does not exist says what failed, but not what the program was trying to do. A string after ? adds that:

use errors

struct Config {
    name: Str,
    port: Int,
}

fn load(fs: Fs, path: Str) -> Result[Config, dyn Error] {
    let text = fs.read(path)? "reading {path}"
    let mut values = map()
    for line in text.lines() {
        let parts = line.split("=")
        if parts.len() == 2 {
            values[parts[0].trim()] = parts[1].trim()
        }
    }
    let name = values.get("name")? "no name in {path}"
    let port = parse_port(values.get("port") ?? "")? "bad port in {path}"
    Ok(Config { name, port })
}

fn parse_port(s: Str) -> Result[Int, Str] {
    match s.parse_int() {
        Some(n) if n > 0 && n < 65536 => Ok(n),
        _ => Err("\"{s}\" is not a port"),
    }
}

fn main(root: Root) {
    for path in ["good.conf", "bad.conf", "missing.conf"] {
        match load(root.fs, path) {
            Ok(c) => root.stdout.print("{path}: {c.name} on port {c.port}"),
            Err(e) => root.stdout.print(errors.report(e)),
        }
    }
}
Listing 8-5: Context on errors, and printing the chain examples/ch08-03-context/main.tsr
$ tessera run main.tsr --grant fs=config:ro
good.conf: shop on port 8080
bad port in bad.conf
caused by: "eighty" is not a port
reading missing.conf
caused by: fs: `missing.conf` does not exist

fs.read(path)? "reading {path}" returns early, on error, with a new error whose message is the context, and whose cause() is the original error. The string is only built when there is an error. On an Option, values.get("name")? "no name in {path}" turns a missing value into an error with that message.

The errors module walks the chain: errors.report(e) prints each link on its own line, errors.root(e) finds the error that started it, and errors.chain(e) lists them all.

Why a string after ? rather than a method call? Because adding context is the common case, and this way it reads like the comment it replaces, costs nothing when there's no error, and stays one short step.

Reporting problems on standard error

Not every failure should stop a program. A program reading many records might skip the bad ones, say so, and carry on. Those messages belong on standard error, not standard output: standard output is the program's result, which may be piped into another program or saved to a file, and a warning mixed into it would spoil it. root.stderr is the capability for standard error. Like root.stdout, it is always granted, and it has the same print and write methods:

fn parse_price(line: Str) -> Result[Int, Str] {
    let parts = line.split("=")
    if parts.len() != 2 {
        return Err("expected name=cents, got \"{line}\"")
    }
    match parts[1].parse_int() {
        Some(cents) => Ok(cents),
        None => Err("\"{parts[1]}\" is not a number"),
    }
}

fn main(root: Root) {
    let lines = ["tea=250", "cake", "scone=abc", "jam=400"]
    let mut total = 0
    let mut problems = 0
    for (i, line) in lines.with_index() {
        match parse_price(line) {
            Ok(cents) => { total = total + cents },
            Err(why) => {
                root.stderr.print("line {i + 1}: {why}")
                problems = problems + 1
            },
        }
    }
    root.stdout.print("total: {total}")
    if problems > 0 {
        root.stderr.print("{problems} lines skipped")
    }
}
Listing 8-6: Results on stdout, problems on stderr examples/ch08-09-stderr/main.tsr
$ tessera run main.tsr
line 2: expected name=cents, got "cake"
line 3: "abc" is not a number
total: 650
2 lines skipped
$ tessera run main.tsr 2> /dev/null
total: 650
$ tessera run main.tsr > /dev/null
line 2: expected name=cents, got "cake"
line 3: "abc" is not a number
2 lines skipped

On a terminal both streams show up together, as in the first run. The next two runs keep only one of them each: 2> /dev/null throws standard error away and leaves the total, and > /dev/null does the opposite and leaves only the problems. That separation is what lets someone run tessera run main.tsr > total.txt and still see the warnings.

The error: line that main prints when it returns an Err, and the message of a panic, go to standard error too. And a function that only reports problems can take a Stderr alone, and so it can't print to standard output by mistake.

Panics

Some failures are bugs: reading index 10 of a three-item list, dividing by zero, a missing map key read with m[k], or calling panic("...") because something impossible happened. They panic:

fn main(root: Root) {
    let xs = [1, 2, 3]
    root.stdout.print("before")
    root.stdout.print("{xs[10]}")
    root.stdout.print("after")
}
Listing 8-7: An index out of range examples/ch08-05-panic/main.tsr
$ tessera run main.tsr
before
panic: index 10 is out of range for a list of length 3
  --> main.tsr:4:25

A panic stops the program with a message and the place it happened, and the exit status is 1. Use Result for anything a caller might reasonably want to handle, and keep panics for "this should never happen". Where you're not sure an index exists, xs.get(i) returns an Option instead of panicking.

Catching a panic

Sometimes a program must survive a bug in one part, such as a server answering one request with an error page instead of going down. catch runs a function and turns a panic into an Err:

fn average(xs: List[Int]) -> Int {
    xs.sum() / xs.len()
}

fn main(root: Root) {
    for xs in [[2, 4, 9], []] {
        let result = catch { average(xs) }
        match result {
            Ok(n) => root.stdout.print("average of {xs}: {n}"),
            Err(msg) => root.stdout.print("could not average {xs}: {msg}"),
        }
    }
}
Listing 8-8: Catching a panic examples/ch08-06-catch/main.tsr
$ tessera run main.tsr
average of [2, 4, 9]: 5
could not average []: division by zero

catch { average(xs) } is catch(|| average(xs)) written with a trailing closure (chapter 9). It returns Ok with the value, or Err with the panic message. Everything the panicking code had allocated is freed, so a loop that catches a panic on every round doesn't leak.

Notice the let result = ... before the match. In the head of a match, if, while or for, a { always starts the body, so a trailing closure can't appear there; put it in a let first, or in parentheses.

catch is for keeping a program alive through bugs, not for control flow. The http server in chapter 14 uses it so that a handler that panics answers 500 and the server keeps serving.

defer

Cleanup should happen however a block is left: at its end, by return, by ?, or by a panic. defer schedules a statement or block to run when the enclosing block is left:

fn step(out: Stdout, n: Int) -> Result[Int, Str] {
    out.print("start {n}")
    defer out.print("end {n}")
    if n == 2 {
        return Err("step {n} failed")
    }
    out.print("working on {n}")
    Ok(n * 10)
}

fn run_all(out: Stdout) -> Result[Int, Str] {
    defer out.print("cleaned up")
    let a = step(out, 1)?
    let b = step(out, 2)?
    Ok(a + b)
}

fn main(root: Root) {
    root.stdout.print("{run_all(root.stdout)}")
}
Listing 8-9: Deferred statements examples/ch08-07-defer/main.tsr
$ tessera run main.tsr
start 1
working on 1
end 1
start 2
end 2
cleaned up
Err("step 2 failed")

step(out, 2) returns early with an error, and its defer still prints end 2. run_all's ? then returns early too, and its own defer prints cleaned up. Several defers in one block run in reverse order, last written first.

A defer belongs to its block, not to the whole function: one inside a loop body runs at the end of every iteration. Its body can't leave the block itself (no return or ? inside it).

Summary

Expected failures are values: Result with Ok and Err, passed up with ?, mixed freely with dyn Error, and explained with a context string after ?. Problems a program reports and survives go to standard error, through root.stderr. Bugs panic; catch contains a panic when a program must keep going. defer runs cleanup on every way out of a block. Capability operations fail with IoError, where Denied means a grant was missing.

Next we'll look at the collections, and at the closures that make them pleasant to use.