Errors
Lyric separates two kinds of failure.
- A panic is a programming error: an index out of range, an unwrap of an absent value. It is
not catchable and ends the process with exit code
101. - An exception is an expected condition that a caller may handle.
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;
}
}