Skip to content

Error handling

Oxie uses Rust’s error model — errors are values of type Result<T, E> — with Kotlin/Swift style syntax on top. There are no exceptions and no hidden costs: everything below compiles to ordinary Result code.

A function that can fail declares throws:

fun parse_age(text: &str): u32 throws {
val age: u32 = text.trim().parse()? // `?` passes a failure on to the caller
if (age > 150) throw "age $age is not realistic"
age // wrapped in Ok automatically
}
fun main() {
println("${parse_age("42"):?}")
println("${parse_age("abc"):?}")
println("${parse_age("200"):?}")
}
pub(crate) fn parse_age(text: &str) -> ::std::result::Result<u32, crate::__oxie::AnyError> {
let age: u32 = text.trim().parse()?;
if age > 150 {
return Err(format!("age {age} is not realistic").into());
}
Ok(age)
}
fn main() {
println!("{:?}", parse_age("42"));
println!("{:?}", parse_age("abc"));
println!("{:?}", parse_age("200"));
}
Ok(42)
Err(ParseIntError { kind: InvalidDigit })
Err("age 200 is not realistic")
Oxie Rust
fun f(): T throws fn f() -> Result<T, AnyError>
fun f(): T throws MyError fn f() -> Result<T, MyError>
fun f() throws fn f() -> Result<(), AnyError>
return x / last expression x return Ok(x) / Ok(x)
throw e return Err(e.into())
expr? expr?

AnyError is Box<dyn std::error::Error + Send + Sync>: any error type converts into it, and so do strings, so throw "message" and throw "value $x is bad" work.

Calling another Oxie function that throws from a throwing function propagates its error automatically when you use the call as a statement or return value — like an exception would:

fun check(x: i32) throws {
if (x < 0) throw "negative: $x"
}
fun check_all(xs: &[i32]) throws {
for (x in xs) check(*x) // same as `check(*x)?`
}
fun main() {
println("${check_all(&[1, 2]):?}")
println("${check_all(&[1, -2]):?}")
}
pub(crate) fn check(x: i32) -> ::std::result::Result<(), crate::__oxie::AnyError> {
if x < 0 {
return Err(format!("negative: {x}").into());
}
Ok(())
}
pub(crate) fn check_all(xs: &[i32]) -> ::std::result::Result<(), crate::__oxie::AnyError> {
for x in xs {
check(*x)?;
}
Ok(())
}
fn main() {
println!("{:?}", check_all(&[1, 2]));
println!("{:?}", check_all(&[1, -2]));
}
Ok(())
Err("negative: -2")

When you assign the result instead (val r = check(5)), you get the Result to inspect.

error enum declares an error type with a message per case. Oxie implements Display, std::error::Error and Debug for it:

import std::num::ParseIntError
error enum ConfigError {
Missing(key: String) = "missing setting '$key'"
OutOfRange(key: String, value: i64, max: i64) = "$key = $value is larger than $max"
BadNumber(ParseIntError) // wraps another error
}
fun read_port(text: &str): u16 throws ConfigError {
if (text.is_empty()) throw ConfigError.Missing("port")
val n: i64 = text.parse()? // ParseIntError converts via From
if (n > 65535) throw ConfigError.OutOfRange("port", n, 65535)
n as u16
}
fun main() {
for (input in listOf("8080", "", "x", "99999")) {
when (val r = read_port(input)) {
Ok(port) -> println("port $port")
Err(e) -> println("error: $e")
}
}
}
use std::num::ParseIntError;
#[derive(Debug)]
pub(crate) enum ConfigError {
Missing { key: String },
OutOfRange { key: String, value: i64, max: i64 },
BadNumber(ParseIntError),
}
impl ::std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
match self {
Self::Missing { key } => write!(f, "missing setting '{key}'"),
Self::OutOfRange { key, value, max } => write!(f, "{key} = {value} is larger than {max}"),
Self::BadNumber(__0) => ::std::fmt::Display::fmt(__0, f),
}
}
}
impl ::std::error::Error for ConfigError {
fn source(&self) -> Option<&(dyn ::std::error::Error + 'static)> {
match self {
Self::BadNumber(e) => Some(e),
#[allow(unreachable_patterns)]
_ => None,
}
}
}
impl ::std::convert::From<ParseIntError> for ConfigError {
fn from(e: ParseIntError) -> Self {
Self::BadNumber(e)
}
}
pub(crate) fn read_port(text: &str) -> ::std::result::Result<u16, ConfigError> {
if text.is_empty() {
return Err(ConfigError::Missing { key: String::from("port") }.into());
}
let n: i64 = text.parse()?;
if n > 65535 {
return Err(ConfigError::OutOfRange { key: String::from("port"), value: n, max: 65535 }.into());
}
Ok(n as u16)
}
fn main() {
for input in vec!["8080", "", "x", "99999"] {
{
let r = read_port(&input);
match r {
Ok(port) => {
println!("port {port}");
}
Err(e) => {
println!("error: {e}");
}
}
}
}
}
port 8080
error: missing setting 'port'
error: invalid digit found in string
error: port = 99999 is larger than 65535
  • = "template" is the message; $field refers to the case’s fields and $0 to positional ones.
  • A case with a single field whose type is an error (its name ends in Error) gets a From conversion, so ? turns that error into this case, and source() returns it. Mark a case @from to get the conversion for other types.
  • A case without a message shows its wrapped error, or its name in words (NotFound → “not found”).

A single error type can be a class:

error class TooBig(value: i64, limit: i64) = "$value exceeds the limit of $limit"
fun check(v: i64): i64 throws TooBig {
if (v > 100) throw TooBig(v, 100)
v
}
fun main() {
println("${check(5):?}")
println("${check(500).unwrap_err()}")
}
#[derive(Debug)]
pub(crate) struct TooBig {
pub(crate) value: i64,
pub(crate) limit: i64,
}
impl TooBig {
#[allow(dead_code)]
pub(crate) fn new(value: i64, limit: i64) -> Self {
Self {
value,
limit,
}
}
}
impl ::std::fmt::Display for TooBig {
fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
write!(f, "{} exceeds the limit of {}", self.value, self.limit)
}
}
impl ::std::error::Error for TooBig {}
pub(crate) fn check(v: i64) -> ::std::result::Result<i64, TooBig> {
if v > 100 {
return Err(TooBig::new(v, 100).into());
}
Ok(v)
}
fn main() {
println!("{:?}", check(5));
println!("{}", check(500).unwrap_err());
}
Ok(5)
500 exceeds the limit of 100

try { } runs a block in which ? and throw jump to catch instead of leaving the function:

fun main() {
val total = try {
val a: i32 = "10".parse()?
val b: i32 = "x".parse()?
a + b
} catch (e) {
println("could not add: $e")
0
}
println("total = $total")
}
fn main() {
let total = match '__oxie_try1: {
let a: i32 = match "10".parse() { Ok(__oxie_v2) => __oxie_v2, Err(__oxie_e3) => break '__oxie_try1 Err(::std::convert::From::from(__oxie_e3)) };
let b: i32 = match "x".parse() { Ok(__oxie_v4) => __oxie_v4, Err(__oxie_e5) => break '__oxie_try1 Err(::std::convert::From::from(__oxie_e5)) };
Ok::<_, crate::__oxie::AnyError>(a + b)
} {
Ok(__oxie_v6) => __oxie_v6,
Err(e) => {
println!("could not add: {e}");
0
}
};
println!("total = {total}");
}
could not add: invalid digit found in string
total = 0
  • catch (e) receives an AnyError. Write catch (e: MyError) to receive a specific type (errors are converted with From).
  • try { } without catch evaluates to a Result: val r = try { a()? + b()? }.
  • It compiles to a Rust labeled block, so return, break and continue inside it behave normally.

?: gives a value when a Result is an error (or an Option is None):

fun main() {
val n = "abc".parse::<i32>() ?: 0
val m = "7".parse::<i32>() ?: 0
println("$n $m")
}
fn main() {
let n = crate::__oxie::ElvisOr::or_val("abc".parse::<i32>(), 0);
let m = crate::__oxie::ElvisOr::or_val("7".parse::<i32>(), 0);
println!("{n} {m}");
}
0 7

The right side can also leave the function: val user = find(id) ?: throw "user $id not found" or ?: return.

.context("...") wraps an error (or a missing Option value) with a message, keeping the original as its cause:

import std::fs
fun load(path: &str): String throws {
val text = fs.read_to_string(path).context("cannot read config '$path'")?
text
}
fun main() {
when (val r = load("/no/such/file.toml")) {
Ok(t) -> println(t)
Err(e) -> {
println("error: $e")
println("cause: ${e.source().unwrap()}")
}
}
}
use std::fs;
pub(crate) fn load(path: &str) -> ::std::result::Result<String, crate::__oxie::AnyError> {
let text = fs::read_to_string(path).context(format!("cannot read config '{path}'"))?;
Ok(text)
}
fn main() {
{
let r = load("/no/such/file.toml");
match r {
Ok(t) => {
println!("{}", t);
}
Err(e) => {
println!("error: {e}");
println!("cause: {}", e.source().unwrap());
}
}
}
}
error: cannot read config '/no/such/file.toml'
cause: No such file or directory (os error 2)

.with_context { "..." } builds the message only when there is an error.

A throwing main prints the error and its causes and exits with status 1:

import std::fs
fun main() throws {
val config = fs.read_to_string("/no/such/app.toml").context("loading app.toml")?
println(config)
}
// exits with an error:
// error: loading app.toml
// caused by: No such file or directory (os error 2)
use std::fs;
fn __oxie_main() -> ::std::result::Result<(), crate::__oxie::AnyError> {
let config = fs::read_to_string("/no/such/app.toml").context("loading app.toml")?;
println!("{}", config);
Ok(())
}
fn main() {
if let Err(err) = __oxie_main() {
crate::__oxie::report(err);
::std::process::exit(1);
}
}

panic("message"), unreachable(), todo() and !! (unwrap) stop the program as in Rust. Use them for bugs, not for expected failures. Under oxie run and oxie test the panic message names the .oxi line (panicked at src/main.oxi:12:5).

Everything interoperates with the Rust ecosystem: thiserror and anyhow work as usual (@derive(thiserror::Error), throws anyhow::Error), and an Oxie error enum is a normal Rust error type that Rust code can match on and convert.