Xo is experimental. The source is now public on GitHub, or try Xo in your browser.
Menu · Error handling

Docs

Error handling

On this page

Xo has two kinds of failure, and the language keeps them apart:

  • an error is something the caller can reasonably handle (bad input, a missing file). It is a value in the return type after !.
  • a fault is a bug (a broken requires, an index out of range). It stops the task, and only an isolation boundary turns it into a value.

Layered errors with from

Each layer has its own error enum. A from variant lets ? convert a lower layer’s error into the upper one.

errors-chain/main.xo

enum ParseErr {
  Empty
  NotANumber(text: Str)
}

fn (e: ParseErr) display() -> Str {
  match e {
    Empty => "empty input"
    NotANumber(text) => "not a number: ${text}"
  }
}

enum RangeErr {
  TooSmall(n: Int, min: Int)
  TooBig(n: Int, max: Int)
}

fn (e: RangeErr) display() -> Str {
  match e {
    TooSmall(n, min) => "${n} is below ${min}"
    TooBig(n, max) => "${n} is above ${max}"
  }
}

/// The error of `port`: either layer's error converts into it, because of `from`.
enum PortErr {
  Parse(from ParseErr)
  Range(from RangeErr)
}

fn (e: PortErr) display() -> Str {
  match e {
    Parse(p) => "bad port text: ${p.display()}"
    Range(r) => "bad port number: ${r.display()}"
  }
}

fn parse(text: Str) -> Int ! ParseErr {
  if text.trim() == "" {
    return Err(ParseErr.Empty)
  }
  Int.parse(text.trim()).or_err(ParseErr.NotANumber(text))?
}

fn check_range(n: Int) -> Int ! RangeErr {
  if n < 1024 {
    return Err(RangeErr.TooSmall(n: n, min: 1024))
  }
  if n > 65535 {
    return Err(RangeErr.TooBig(n: n, max: 65535))
  }
  n
}

/// Each `?` returns early with the error converted to `PortErr`.
fn port(text: Str) -> Int ! PortErr {
  let n = parse(text)?
  check_range(n)?
}

fn main(os: Os) {
  for text in ["8080", "", "http", "80", "70000"] {
    match port(text) {
      Ok(p) => os.stdio.println("port ${p}")
      Err(e) => os.stdio.println("error: ${e.display()}")
    }
  }
}

test "the error says which layer failed" {
  match port("80") {
    Ok(_) => fault("expected an error")
    Err(Range(TooSmall(n, _))) => expect(n) == 80
    Err(e) => fault("unexpected: ${e.display()}")
  }
}

Output:

port 8080
error: bad port text: empty input
error: bad port text: not a number: http
error: bad port number: 80 is below 1024
error: bad port number: 70000 is above 65535
  • port returns Int ! PortErr. parse(text)? yields a ParseErr, which the Parse(from ParseErr) variant wraps; check_range(n)? does the same for RangeErr.
  • The caller can still tell which layer failed: the test matches Err(Range(TooSmall(n, _))).
  • Variants with two fields are built with named arguments.

Ignoring a result is not allowed to pass silently:

fn parse_port(s: Str) -> Int ! Error {
  Int.parse(s).or_err("not a number")?
}

fn main(os: Os) {
  parse_port("8080")
}

xo check says:

main.xo:6:3: error XO0201: result of `parse_port` is not handled
  note: `parse_port` can fail with `Error`; propagate with `?`, match on it, or discard it with `.ignore_err("reason")` (core 2.6)
  fix: discard with a reason
1 module(s), 1 error(s), 0 warning(s)

Handling an error without ceremony

errors-handling/main.xo

fn lookup(env: Map[Str, Str], key: Str) -> Str? {
  env.get(key)
}

/// `??` takes the value, or the right side when there is none.
fn port_or_default(env: Map[Str, Str]) -> Int {
  let text = lookup(env, "PORT") ?? return 8080
  Int.parse(text) ?? 8080
}

/// Turn a missing value into an error with `or_err`, and pass errors up with `?`.
fn strict_port(env: Map[Str, Str]) -> Int ! Error {
  let text = lookup(env, "PORT").or_err("PORT is not set")?
  Int.parse(text).or_err("PORT is not a number: ${text}")?
}

/// `r ?? e => body` handles the error and gives the fallback.
fn port_logged(out: Stdio, env: Map[Str, Str]) -> Int uses stdio {
  strict_port(env) ?? e => {
    out.println("falling back: ${e.display()}")
    8080
  }
}

fn main(os: Os) {
  let cases: List[Map[Str, Str]] = [{"PORT": "9000"}, {"PORT": "x"}, {}]
  for env in cases {
    os.stdio.println("default: ${port_or_default(env)}")
    os.stdio.println("logged: ${port_logged(os.stdio, env)}")
    match strict_port(env) {
      Ok(p) => os.stdio.println("strict: ${p}")
      Err(e) => os.stdio.println("strict failed: ${e.display()}")
    }
    if let Some(p) = lookup(env, "PORT") {
      os.stdio.println("PORT is set to ${p}")
    }
  }
}

test "the fallback only applies on an error" {
  expect(port_or_default({"PORT": "9000"})) == 9000
  expect(port_or_default({"PORT": "x"})) == 8080
  expect(port_or_default({})) == 8080
}

Output:

default: 9000
logged: 9000
strict: 9000
PORT is set to 9000
default: 8080
falling back: PORT is not a number: x
logged: 8080
strict failed: PORT is not a number: x
PORT is set to x
default: 8080
falling back: PORT is not set
logged: 8080
strict failed: PORT is not set
  • a ?? b gives the value, or b when there is none. With a Result the right side may bind the error: r ?? e => { ... }.
  • ?? return x leaves the function early when the value is missing.
  • or_err turns an Option into a Result; ? passes the error up.
  • if let Some(p) = ... handles just the case you care about.

Faults are not errors

errors-faults/main.xo

use std/task

/// A bad index is a bug in the caller, not an error to handle: it is a fault.
fn nth(xs: List[Int], i: Int) -> Int
  requires i >= 0 && i < xs.len()
{
  xs[i]
}

/// Bad input from outside is an error: the caller decides what to do.
fn nth_checked(xs: List[Int], i: Int) -> Int ! Error {
  if i < 0 || i >= xs.len() {
    return Err("index ${i} out of range for ${xs.len()} items")
  }
  xs[i]
}

fn main(os: Os) {
  let xs = [10, 20, 30]
  os.stdio.println("nth: ${nth(xs, 1)}")
  match nth_checked(xs, 7) {
    Ok(v) => os.stdio.println("got ${v}")
    Err(e) => os.stdio.println("error: ${e.display()}")
  }
  // `task.isolate` runs code in its own scope and turns a fault into a value
  match task.isolate(fn() -> Int ! Error { Ok(nth(xs, 7)) }) {
    Ok(v) => os.stdio.println("got ${v}")
    Err(Faulted(f)) => os.stdio.println("fault caught: ${f.msg}")
    Err(Failed(e)) => os.stdio.println("error: ${e.display()}")
  }
}

Output:

nth: 20
error: index 7 out of range for 3 items
fault caught: requires failed: i >= 0 && i < xs.len()
  • nth states its precondition with requires. Breaking it is the caller’s bug, so it is a fault, not an error value.
  • nth_checked takes input that may legitimately be wrong, so it returns an error.
  • task.isolate runs a function in its own scope and returns Faulted when it faults. Use it at a service boundary (one request), not to hide bugs.

What to take away

  • Input from outside is an error; a broken promise inside the program is a fault.
  • Give each layer its own error enum and connect them with from.
  • Prefer ?? for fallbacks and match when each case needs its own action.

Back to the book overview.

↑ index