Lyric v4.5.0

Errors

Lyric separates two kinds of failure.

Whether, or why

The standard library divides the recoverable world by the QUESTION, not by the operation (since 3.7): a value answers whether — ?T for "is it there?", bool for "did it happen?" — and a throw answers why not. Where a failure carries a reason worth acting on, a silent form has an OrThrow twin that declares a module-specific error type; both come from one implementation, so the twin throws exactly where the silent form answers null or false. Which one you call states what you will do with a failure: fall back, or handle the reason.

import std.io.file { text, textOrThrow };
import std.io.error { IoError, IoErrorKind };
import std.io.console { println };

fn main(): int {
    // Falling back: absence is an ordinary answer, no ceremony.
    let motd = text("motd.txt") ?? "no message today";
    println(motd);

    // Handling the reason: the twin says which kind, on which path, and what the platform said.
    try {
        println(textOrThrow("config.json"));
    } catch (e: IoError) {
        println(match (e.kind) {
            IoErrorKind.NotFound => "no config — using defaults",
            _ => "config unreadable: " + e.message(),
        });
    }
    return 0;
}

The twins in 3.7: every read and operation of std.io.file (throwing IoError, whose kind is a matchable IoErrorKind), std.json.parseOrThrow (a JsonError naming line and column), the std.encoding decoders (an EncodingError naming the offset), and std.string.utf8DecodeOrThrow (a Utf8Error naming the byte). Where null already IS the whole truth — an unset environment variable, a key a map does not hold, parseInt on a short input — the silent form stands alone, deliberately.

Panics

import std.core { panic, assert };

fn take(items: int[], index: int): int {
    if (index < 0 || index >= items.length) {
        panic("index out of range");
    }
    return items[index];
}

fn main(): int {
    assert(true, "this holds");
    return take([1, 2, 3], 1);
}

std.core also offers todo and unreachable for the same purpose.

Exceptions

A function that can throw declares throws. A caller either handles it or declares throws itself.

throws without a type means Throwable — anything. Naming a type narrows the declaration, and a catch for that type then covers the call completely.

import std.core { Exception };

fn parse(text: string): int throws Exception {
    if (text == "") {
        throw Exception { text = "empty input" };
    }
    return 1;
}

fn main(): int {
    try {
        let n = parse("");
        return n;
    } catch (e: Exception) {
        return 0;
    }
}

A catch may bind by type, bind everything, or bind nothing:

import std.core { Exception };

fn risky(): int throws {
    throw Exception { text = "no" };
}

fn main(): int {
    try {
        return risky();
    } catch (e: Exception) {
        return 1;
    } catch (other) {
        return 2;
    }
}

main cannot declare throws; an exception that reaches it aborts the process.

Cleanup

defer runs when the scope ends, on the normal path and while unwinding:

import std.core { Exception };
import std.io.console { println };

fn work(): int throws Exception {
    defer println("released");

    throw Exception { text = "failed" };
}

fn main(): int {
    try {
        return work();
    } catch (e: Exception) {
        return 0;
    }
}

There is no finally; defer covers it.

Custom exception types

Any type that satisfies Throwable can be thrown. Throwable is built in and needs no import; std.core.Exception is the ready-made implementation, whose field is text and whose method is message().

class NotFound :: [Throwable] {
    what: string,
    fn message(): string { return "not found: " + this.what; }
}

fn lookup(key: string): int throws NotFound {
    throw NotFound { what = key };
}

fn main(): int {
    try {
        return lookup("key");
    } catch (e: NotFound) {
        return 0;
    }
}