Chapter 12: Error handling
Loup has two ways to report failure. An exception is thrown with throw and travels up the call stack until a try/catch handles it. A Result[T, E] is an ordinary return value that the caller inspects (chapter 8). This chapter covers both: the built-in exceptions, declaring your own, catching and re-throwing, chaining causes, Result with ? and attempt, and what an uncaught exception looks like in a native binary.
Throwing and catching
throw takes an exception object. try runs a block, and each catch (Type as name) clause handles exceptions of that type (or a subtype); the first matching clause wins. A user exception is a class that extends Exception and passes its message to super:
@mode(host.native)
class InsufficientFundsError : Exception {
pub let requested: int;
pub let available: int;
new(requested: int, available: int) {
super(i"requested {$ requested $}, available {$ available $}");
this.requested = requested;
this.available = available;
}
}
class Wallet {
var balance: int;
new(balance: int) { this.balance = balance; }
pub modifier func withdraw(amount: int): void {
if (amount > this.balance) {
throw InsufficientFundsError(amount, this.balance);
}
this.balance = this.balance - amount;
}
pub func remaining(): int { return this.balance; }
}
func main(): void {
let wallet = Wallet(100);
safe {
try {
wallet.withdraw(30);
wallet.withdraw(500);
} catch (InsufficientFundsError as e) {
println(i"short by {$ e.requested - e.available $}: {$ e.message $}");
}
}
println(wallet.remaining());
}
Listing 12-1: A custom exception with extra fields
short by 430: requested 500, available 70
70
The first withdrawal succeeded and the second threw, so the balance is 70. Every exception has a message and an optional cause (below). One clause can catch several types, catch (IndexError | KeyError as e), and catch (Exception as e) catches everything. Inside a handler, throw e; re-throws. A finally block runs on every path out of the try (Listing 5-9).
The built-in exceptions
The runtime throws these, and you can throw and catch them too (Language Reference §1-18):
| Exception | Thrown by |
|---|---|
ValueError, TypeError |
invalid arguments; conventional for your own validation |
IndexError, KeyError |
xs[i] out of range, d[k] with a missing key |
IoError |
file and process operations (readText, writeText, …) |
NumericOverflowError, IntegerDivisionError |
checked arithmetic, division by zero (chapter 3) |
UnwrapError, RenderError |
unwrap() on None, printing an Err (chapter 8) |
TimeoutError |
operations with a deadline |
RuntimeError |
everything else the runtime detects |
All of them derive from Exception. Declare your own exception classes for your program's failures, as in Listing 12-1, rather than overloading the built-in ones.
Mistake: the catch syntax from TypeScript or Kotlin
@mode(host.native)
func main(): void {
try {
println(readText("/etc/app/missing.toml"));
} catch (e: IoError) {
println("missing");
}
}
main.lphc:6:15: error[E0001]: expected RParen, found Colon
main.lphc:6:15: error[E0002]: expected `{`
main.lphc:6:15: error[E0001]: expected expression
main.lphc:6:17: error[E0003]: expected `;`
main.lphc:9:1: error[E0001]: expected expression
The type comes first and the name after as: catch (IoError as e). Only the first message matters here; the rest follow from the parser having lost its place.
Mistake: exception names from other languages
Python has IOError and Java has FileNotFoundException; Loup has neither. The I/O exception is IoError, and there is no separate "file not found" class:
@mode(host.native)
func main(): void {
try {
println(readText("/etc/app/missing.toml"));
} catch (IOError as e) {
println("missing");
}
}
main.lphc:6:14: error[E0001]: undefined type 'IOError'
FileNotFoundError, NetworkError and ParseError are likewise undefined. Catch IoError and read its message, or declare a class such as class ParseError : Exception { … } for your own parser.
Chaining exceptions
When you catch a low-level exception and throw a higher-level one, attach the original as the cause with from. The cause is kept, printed if the exception is never caught, and readable as e.cause:
@mode(host.native)
class ConfigError : Exception {
new(msg: string) { super(msg); }
}
func loadConfig(path: string): string {
try {
return readText(path);
} catch (IoError as e) {
throw ConfigError(i"cannot load {$ path $}") from e;
}
}
func main(): void {
println(loadConfig("/etc/app/missing.toml"));
}
Uncaught ConfigError: cannot load /etc/app/missing.toml
at loadConfig (main.lphc:11:15)
at main (main.lphc:16:13)
Caused by:
Uncaught IoError: read_text failed on '/etc/app/missing.toml': No such file or directory (os error 2)
at loadConfig (main.lphc:9:16)
at main (main.lphc:16:13)
The process exits with status 1. A handler can walk the chain itself:
func isDigits(text: string): bool {
if (text.length() == 0) {
return false;
}
for (c in text.toCharList()) {
if (c < "0" or c > "9") {
return false;
}
}
return true;
}
func loadPort(text: string): int {
try {
if (not isDigits(text)) {
throw ValueError(i"not a number: {$ text $}");
}
return text.toInt();
} catch (ValueError as e) {
throw ConfigError("invalid port setting") from e;
}
}
func main(): void {
try {
println(loadPort("8080"));
println(loadPort("80a"));
} catch (ConfigError as e) {
println(i"config error: {$ e.message $}");
match e.cause {
Some(c) => println(i" caused by: {$ c.message $}"),
None => println(" no cause recorded"),
}
}
}
Listing 12-2: Reading an exception's cause
8080
config error: invalid port setting
caused by: not a number: 80a
Mistake: trusting toInt()
Listing 12-2 checks the digits itself because string.toInt() does not report bad input in 1.25.1. It returns 0:
@mode(host.native)
func main(): void {
let port = "80a".toInt();
println(i"listening on {$ port $}");
}
listening on 0
Validate text before converting it, as isDigits does, whenever the input comes from a user or a file.
Uncaught exceptions in a native binary
Under loup run, an uncaught exception prints Uncaught, the type and message, a stack trace, and any causes, as above. A native binary prints exactly one line on standard error, the class name and message, and exits with status 1:
ConfigError: cannot load /etc/app/missing.toml
There is no stack trace in a native binary today. If you need one in production, catch exceptions at the top of main, log what you need (including e.cause), and return a non-zero status. When an exception unwinds through native frames, the reference counts held by those frames are released.
Result for expected failures
An exception suits failures that the immediate caller cannot do much about and that should travel up to a handler. A Result[T, E] suits failures that are a normal outcome the caller must deal with, such as invalid input or a lookup that misses. The ? operator keeps code that passes Results along readable (Listing 8-5).
The two styles meet in attempt. Inside a try block, attempt expr unwraps an Ok, or jumps to a catch Err(e) clause with the error value (Language Reference §1-5.10):
func parsePort(text: string): Result[int, string] {
let n = text.toInt();
if (n <= 0 or n > 65535) {
return Err(i"bad port: {$ text $}");
}
return Ok(n);
}
func main(): void {
var port = 0;
try {
port = attempt parsePort("99999");
} catch Err(e) {
println(i"falling back: {$ e $}");
port = 8080;
}
println(port);
}
Listing 12-3: attempt with a catch Err clause
falling back: bad port: 99999
8080
catch Err(e) handles Result errors only; it is not an exception handler, which is why it also works in freestanding code where exceptions do not exist.
Some rules of thumb:
- Return a
Resultfrom functions whose failure is part of their job (parsing, validation, lookups), and let the caller decide. - Throw for broken assumptions and for failures that should abort a whole operation; catch them at a boundary (a request handler,
main) rather than around every call. - When converting one into the other, keep the original information:
from efor exceptions, a descriptive error value forResults. - Do not print a
Resultdirectly;matchon it (chapter 8).
Summary
throw X(...)raises;catch (Type as e),catch (A | B as e)andcatch (Exception as e)handle;finallyalways runs;throw e;re-throws.- User exceptions extend
Exceptionand callsuper(message). Built-ins includeValueError,IndexError,KeyError,IoError(notIOError),NumericOverflowErrorandUnwrapError; there is noFileNotFoundError. throw New(...) from echains a cause, shown asCaused by:and readable ase.cause.loup runprints a stack trace for an uncaught exception; a native binary prints oneClass: messageline. Both exit with status 1.string.toInt()returns 0 for bad input in 1.25.1; validate first.Resultis for expected failures;attempthands anErrto acatch Err(e)clause.- Chapter 13 covers modules and packages.