Chapter 10: Error Handling
What you'll learn: the three ways a Loup script deals with failure — Option[T] when a value may be absent, Result[T, E] when an operation can fail in an expected way, and exceptions for failures that should unwind the stack — plus the ? and attempt operators that pass a Result's error along without a match at every step.
Files go missing, input is malformed, a lookup finds nothing. Loup gives you a tool for each situation, and choosing the right one is most of the skill:
| Situation | Tool |
|---|---|
| A value might not be there (a lookup, an optional setting) | Option[T]: Some(value) or None |
| An operation can fail and the caller should decide what to do (parsing, validation) | Result[T, E]: Ok(value) or Err(error) |
| Something went wrong that the code at hand cannot handle (a missing file, a bug) | an exception: throw, try/catch/finally |
Option, Result, Some, None, Ok and Err are built in; you do not import them.
Option[T]: Values That Might Be Absent
A function that may not find what it looks for returns an Option:
@mode(host.scripted)
struct User {
id: int;
name: string;
}
let users = [
User(id = 1, name = "Ada"),
User(id = 2, name = "Grace"),
];
func findUser(id: int): Option[User] {
for (user in users) {
if (user.id == id) {
return Some(user);
}
}
return None;
}
# 1. match on the two cases
let greeting = match findUser(2) {
Some(u) => i"Hello, {$ u.name $}",
None => "Hello, stranger",
};
println(greeting);
# 2. ?. reaches into an Option; the result is still an Option
println(repr(findUser(1)?.name));
# 3. ?? supplies a default when the value is None
println(findUser(7)?.name ?? "nobody");
# 4. unwrap() when absence would be a bug
println(findUser(1).unwrap().name);
println(findUser(9).is_none());
Listing 10-1: Four ways to use an Option
Hello, Grace
Some("Ada")
nobody
Ada
true
matchforces you to say what happens in both cases.x?.fieldisNonewhenxisNoneandSome(x.field)otherwise.x ?? fallbackunwraps theOption, or evaluatesfallbackwhen it isNone.unwrap()returns the value or throwsUnwrapError;expect("message")does the same with your message. Use them only whereNonewould mean a bug.
T? is shorthand for Option[T]: a field declared email: string? holds an Option[string].
Mistake: the spec's camelCase method names
The Language Reference writes the Option and Result methods in camelCase (isSome, unwrapOr). The 1.25 toolchain implements them in snake_case: is_some, is_none, unwrap_or, is_ok, is_err, unwrap_err, map_err. On an Option, a camelCase name is a compile error:
@mode(host.scripted)
let port: Option[int] = Some(8080);
println(port.isSome());
main.lpscr:4:9: error[E0001]: type 'Option[int]' has no method 'isSome'
The fix is port.is_some(). On a Result the mistake is worse, as the next section shows.
Result[T, E]: Operations That Can Fail
A function whose failure is part of its normal behaviour returns a Result. The error type E can be anything: a string, a struct, a class.
@mode(host.scripted)
from system.text import (parseInt);
## A TCP port number from text, or a description of what is wrong with it.
func parsePort(text: string): Result[int, string] {
let n = parseInt(text)?;
if (n < 1 or n > 65535) {
return Err(i"port out of range: {$ n $}");
}
return Ok(n);
}
for (input in ["8080", "99999", "http"]) {
match parsePort(input) {
Ok(port) => println(i"{$ input $}: port {$ port $}"),
Err(reason) => println(i"{$ input $}: {$ reason $}"),
}
}
println(parsePort("443").is_ok());
println(parsePort("http").unwrap_or(80));
Listing 10-2: Returning and matching a Result
8080: port 8080
99999: port out of range: 99999
http: invalid integer: http
true
80
parseInt from system.text returns Result[int, string]. The ? after it is explained in the next section; for now, read it as "if this is an Err, return that error from parsePort".
Mistake: unwrapOr on a Result
On a Result, the camelCase spelling is not caught by the compiler. The script type-checks, runs, and fails when it reaches the call:
@mode(host.scripted)
from system.text import (parseInt);
let port = parseInt("http").unwrapOr(80);
println(port);
Uncaught RuntimeError: internal interpreter limitation: unsupported instruction: CallVirtual unwrapOr on user class Result — method not found
The fix is the snake_case name, parseInt("http").unwrap_or(80). When you see "method not found" on Result at run time, check the spelling first.
Mistake: converting text with toInt()
string has a toInt() method, and it is tempting to use it for input:
@mode(host.scripted)
let lines = ["42", "4x2", ""];
var total = 0;
for (line in lines) {
total = total + line.toInt();
}
println(total);
42
toInt() returns 0 for text that is not a number, so two bad lines were silently counted as zeros. For input you do not control, use parseInt (or parseFloat) from system.text, which returns a Result you must handle:
@mode(host.scripted)
from system.text import (parseInt);
let lines = ["42", "4x2", ""];
var total = 0;
for (line in lines) {
match parseInt(line) {
Ok(n) => total = total + n,
Err(reason) => println(i"skipping: {$ reason $}"),
}
}
println(total);
skipping: invalid integer: 4x2
skipping: invalid integer:
42
The ? Operator
Inside a function that returns a Result, expr? unwraps an Ok and, on an Err, returns that error from the function immediately. A chain of fallible steps reads like straight-line code:
@mode(host.scripted)
from system.text import (parseInt);
func parsePort(text: string): Result[int, string] {
let n = parseInt(text)?;
if (n < 1 or n > 65535) {
return Err(i"port out of range: {$ n $}");
}
return Ok(n);
}
## "host:port" split into its parts.
func parseAddress(text: string): Result[(string, int), string] {
let parts = text.split(":");
if (parts.length() != 2) {
return Err(i"expected host:port, got '{$ text $}'");
}
let port = parsePort(parts[1])?;
return Ok((parts[0], port));
}
println(repr(parseAddress("localhost:8080")));
println(repr(parseAddress("localhost:99999")));
println(repr(parseAddress("localhost")));
Listing 10-3: ? passes each step's error to the caller
Ok(("localhost", 8080))
Err("port out of range: 99999")
Err("expected host:port, got 'localhost'")
Mistake: ? in a function that does not return Result
? needs somewhere to send the error, so it only works inside a function whose return type is a Result:
@mode(host.scripted)
from system.text import (parseInt);
func doubled(text: string): int {
let n = parseInt(text)?;
return n * 2;
}
println(doubled("21"));
main.lpscr:6:13: error[E_PROPAGATE_WRONG_RETURN]: E_PROPAGATE_WRONG_RETURN: the '?' operator can only be used inside a function returning Result (§1-5.7)
The same error appears if you use ? at the top level of a script. Either make the function return a Result (func doubled(text: string): Result[int, string] and return Ok(n * 2);), or handle the error where it occurs with match or unwrap_or.
Mistake: ? on an Option
The Language Reference says ? applies only to a Result and rejects it on anything else (E_PROPAGATE_NOT_RESULT, §1-5.7). The 1.25 compiler rejects it on an int or a string, but not on an Option, and what it does then is wrong:
@mode(host.scripted)
func portSetting(config: dict[string, string]): Result[string, string] {
let raw = config.get("port")?;
return Ok(raw);
}
let empty: dict[string, string] = {};
println(repr(portSetting(empty)));
Err(None)
config.get returns an Option. The None was turned into an Err carrying None — not the string the signature promises, and not a message anyone can act on. Convert the Option yourself, with ?? or match:
@mode(host.scripted)
func portSetting(config: dict[string, string]): Result[string, string] {
let raw = match config.get("port") {
Some(value) => value,
None => {
return Err("missing setting 'port'");
},
};
return Ok(raw);
}
let empty: dict[string, string] = {};
println(repr(portSetting(empty)));
println(repr(portSetting({"port": "8080"})));
Err("missing setting 'port'")
Ok("8080")
Converting Error Types with From[E]
When the step's error type differs from the function's, ? converts it with the function's error type's from method. The error type declares the conversion by implementing From[E]:
@mode(host.scripted)
struct ParseError {
line: int;
reason: string;
}
class AppError implements From[ParseError] {
pub let message: string;
new(message: string) {
this.message = message;
}
pub static func from(err: ParseError): AppError {
return AppError(i"line {$ err.line $}: {$ err.reason $}");
}
}
func parseCount(line: int, text: string): Result[int, ParseError] {
let n = text.toInt();
if (n <= 0) {
return Err(ParseError(line = line, reason = i"not a positive count: '{$ text $}'"));
}
return Ok(n);
}
func total(lines: list[string]): Result[int, AppError] {
var sum = 0;
var lineNo = 1;
for (text in lines) {
sum = sum + parseCount(lineNo, text)?;
lineNo = lineNo + 1;
}
return Ok(sum);
}
for (input in [["3", "4", "5"], ["3", "four", "5"]]) {
match total(input) {
Ok(n) => println(i"total {$ n $}"),
Err(e) => println(i"error: {$ e.message $}"),
}
}
Listing 10-4: ? converts a ParseError into an AppError
total 12
error: line 2: not a positive count: 'four'
(Here toInt()'s zero for bad text is caught by the n <= 0 check, so it is safe.) Leave out implements From[ParseError] and the compiler says exactly what is missing:
@mode(host.scripted)
struct ParseError {
line: int;
reason: string;
}
class AppError {
pub let message: string;
new(message: string) {
this.message = message;
}
}
func parseCount(line: int, text: string): Result[int, ParseError] {
return Ok(text.toInt());
}
func total(lines: list[string]): Result[int, AppError] {
var sum = 0;
for (text in lines) {
sum = sum + parseCount(1, text)?;
}
return Ok(sum);
}
println(repr(total(["1"])));
main.lpscr:23:21: error[E_PROPAGATE_MISSING_FROM]: E_PROPAGATE_MISSING_FROM: '?' propagates an error of type 'ParseError' out of a function whose error type is 'AppError', which does not implement `From[ParseError]` (§1-5.7)
attempt: Handling Several Results in One Place
? sends an error out of the function. Sometimes you want to handle it right here instead, without a match around every call. attempt expr unwraps an Ok; on an Err it jumps to the catch Err(e) arm of the enclosing try (§1-5.10):
@mode(host.scripted)
from system.text import (parseInt);
func parsePort(text: string): Result[int, string] {
let n = parseInt(text)?;
if (n < 1 or n > 65535) {
return Err(i"port out of range: {$ n $}");
}
return Ok(n);
}
## Both ports, or the defaults if either is bad.
func portsOrDefault(http: string, https: string): (int, int) {
try {
let first = attempt parsePort(http);
let second = attempt parsePort(https);
return (first, second);
} catch Err(e) {
println(i"using defaults: {$ e $}");
return (8080, 8443);
}
}
println(portsOrDefault("80", "443"));
println(portsOrDefault("80", "https"));
Listing 10-5: attempt sends every failure to one handler
(80, 443)
using defaults: invalid integer: https
(8080, 8443)
catch Err(e) is not an exception handler: it only receives errors from attempt. A try may have one catch Err arm alongside ordinary catch clauses and a finally.
attempt needs that arm to exist:
@mode(host.scripted)
from system.text import (parseInt);
let port = attempt parseInt("80");
println(port);
main.lpscr:5:12: error[E_ATTEMPT_OUTSIDE_BLOCK]: 'attempt' requires an enclosing 'try' block with a 'catch Err(e)' arm
Exceptions
Exceptions are for failures that the code where they happen cannot sensibly handle: the file is not there, the data is corrupt, an index is out of range. They unwind the call stack until a catch handles them, so the functions in between do not mention them in their signatures.
Catching an Exception
The file functions throw IoError when the operating system refuses:
@mode(host.scripted)
func loadSettings(path: string): string {
return readText(path);
}
try {
let text = loadSettings("missing-settings.toml");
println(text);
} catch (IoError as e) {
println(i"could not read settings: {$ e.message $}");
println(i"error number: {$ e.code $}");
} finally {
println("done");
}
Listing 10-6: try, catch and finally
could not read settings: read_text failed on 'missing-settings.toml': No such file or directory (os error 2)
error number: 2
done
catch (Type as name)handles exceptions of that type and its subclasses, and binds the exception toname.- Every exception has a
message.IoErroralso hascode, the operating system's error number (2is "no such file"). finallyruns whether thetryblock finished, threw, or returned. Areturn,breakorcontinueinsidefinallyis a compile error (E_FINALLY_CONTROL).catchclauses are tried top to bottom, so put specific types first andcatch (Exception as e)last. A barecatch { … }handles anyException.
Mistake: catch (e: Type)
TypeScript, Kotlin and Swift habits produce this:
@mode(host.scripted)
try {
let text = readText("settings.toml");
println(text);
} catch (e: IoError) {
println(e.message);
}
main.lpscr:6:11: error[E0001]: expected RParen, found Colon
main.lpscr:6:11: error[E0002]: expected `{`
main.lpscr:6:11: error[E0001]: expected expression
main.lpscr:6:13: error[E0003]: expected `;`
main.lpscr:8:1: error[E0001]: expected expression
Only the first line matters; the rest follow from it. In Loup the type comes first and the name after as: catch (IoError as e).
Mistake: an exception class that does not exist
Python and Java names do not carry over:
@mode(host.scripted)
try {
let text = readText("settings.toml");
println(text);
} catch (FileNotFoundError as e) {
println("no settings file");
}
main.lpscr:6:10: error[E0001]: undefined type 'FileNotFoundError'
IOError (capital O) gives the same error. A missing file is an IoError whose code is 2:
@mode(host.scripted)
try {
let text = readText("settings.toml");
println(text);
} catch (IoError as e) {
if (e.code == 2) {
println("no settings file");
} else {
println(i"cannot read settings: {$ e.message $}");
}
}
no settings file
The built-in exception classes you will meet most are:
| Class | Thrown when |
|---|---|
IoError |
a file, process or socket operation fails (has code) |
ValueError |
a value is unacceptable for the operation |
TypeError |
a value has the wrong type at run time |
IndexError |
a list index is out of range |
KeyError |
d[key] names a key that is not in the dict |
IntegerDivisionError |
an integer is divided by zero |
UnwrapError |
unwrap() or expect() meets None or Err |
RuntimeError |
a general failure with no more specific class |
TimeoutError |
an operation ran out of time |
All of them extend Exception. There is no built-in FileNotFoundError, NetworkError or ParseError; declare your own classes for those (next section). You can throw any of the classes above except IoError, which only the runtime raises.
Several Types in One Clause
@mode(host.scripted)
let stock = {"bolt": 100, "nut": 250};
let bins = ["bolt", "nut"];
for (i in [0, 5]) {
try {
let name = bins[i];
println(i"{$ name $}: {$ stock[name] $}");
} catch (IndexError | KeyError as e) {
println(i"lookup failed: {$ e.message $}");
}
}
bolt: 100
lookup failed: list index 5 out of range (len 2)
Throwing and Defining Exceptions
throw takes an exception object. Your own exception classes extend Exception, can add fields, and pass the message to super:
@mode(host.scripted)
class ValidationError : Exception {
pub let field: string;
new(field: string, reason: string) {
super(i"{$ field $}: {$ reason $}");
this.field = field;
}
}
func checkAge(age: int): int {
if (age < 0) {
throw ValidationError("age", "must not be negative");
}
if (age > 150) {
throw ValidationError("age", "must be at most 150");
}
return age;
}
for (age in [42, -1, 200]) {
try {
println(i"accepted {$ checkAge(age) $}");
} catch (ValidationError as e) {
println(i"rejected {$ e.field $} ({$ e.message $})");
}
}
Listing 10-7: A custom exception class
accepted 42
rejected age (age: must not be negative)
rejected age (age: must be at most 150)
A class that declares no constructor gets the built-in one, so class ConfigError : Exception {} followed by throw ConfigError("…") is enough for a simple error.
Read an exception's message field rather than interpolating the exception itself: i"{$ e $}" prints something like IndexError(), not the message.
Mistake: throwing a string
@mode(host.scripted)
func checkAge(age: int): int {
if (age < 0) {
throw "age must not be negative";
}
return age;
}
println(checkAge(-1));
main.lpscr:5:9: error[E_THROW_NON_ERROR]: cannot throw value of type 'string': thrown values must extend Error
Only exception objects can be thrown. Wrap the text in an exception class: throw ValueError("age must not be negative");. (The Language Reference calls this error E_THROW_NOT_EXCEPTION and the base class Exception; the 1.25 message still says Error.)
Uncaught Exceptions
An exception that nothing catches ends the script. loup run prints Uncaught, the class, the message and the call stack, and exits with status 1:
@mode(host.scripted)
class ValidationError : Exception {}
func checkAge(age: int): int {
if (age < 0) {
throw ValidationError("age must not be negative");
}
return age;
}
println(checkAge(30));
println(checkAge(-1));
println("never printed");
30
Uncaught ValidationError: age must not be negative
at checkAge (main.lpscr:7:15)
at <main> (main.lpscr:13:9)
The innermost frame is listed first: the exception was created in checkAge at line 7 and the call came from line 13 of the script.
Chaining: throw … from
When you catch a low-level exception and throw a more meaningful one, keep the original as the cause:
@mode(host.scripted)
class ConfigError : Exception {}
func loadConfig(path: string): string {
try {
return readText(path);
} catch (IoError as e) {
throw ConfigError(i"cannot load config '{$ path $}'") from e;
}
}
try {
loadConfig("app.toml");
} catch (ConfigError as e) {
println(i"handled: {$ e.message $}");
}
loadConfig("app.toml");
Listing 10-8: Keeping the original exception as the cause
handled: cannot load config 'app.toml'
Uncaught ConfigError: cannot load config 'app.toml'
at loadConfig (main.lpscr:9:15)
at <main> (main.lpscr:19:1)
Caused by:
Uncaught IoError: read_text failed on 'app.toml': No such file or directory (os error 2)
at loadConfig (main.lpscr:7:16)
at <main> (main.lpscr:19:1)
The cause is also available in code as e.cause, an Option.
⚠ Planned. The Language Reference (§1-6.10) says that an exception thrown inside a
catchblock withoutfromautomatically gets the caught exception as its cause. The 1.25 toolchain does not do this —e.causeisNone— so writefrom ewhenever you want the chain.
Mistake: panic
Many languages have a panic or abort for "this cannot happen". The Language Reference lists panic(msg) in every mode, but in Loup 1.25 it is not available in hosted code:
@mode(host.scripted)
func checkedRatio(part: int, whole: int): int {
if (whole == 0) {
panic("whole must not be zero");
}
return part * 100 / whole;
}
println(checkedRatio(1, 4));
main.lpscr:5:9: error[E0001]: undefined identifier `panic`
Throw an exception instead — throw RuntimeError("whole must not be zero"); — which a caller can catch or which ends the script with a trace. For a point that really cannot be reached, the unreachable expression stops the program with Uncaught RuntimeError: panic: reached an 'unreachable' expression; it cannot be caught.
Cleaning Up
finally (Listing 10-6) runs cleanup on every path out of a try. For a resource object, with (name = expr) { … } calls the object's close() when the block ends, even when it ends with an exception, provided its class implements Closeable; Chapter 4 introduces it and Chapter 19 covers it in depth.
⚠ Planned.
defer action;(§1-6.5) is meant to runactionwhen the enclosing block exits. The 1.25 toolchain acceptsdeferbutloup runnever executes the deferred action. Until it does, usetry/finallyorwithfor cleanup.
Choosing the Right Mechanism
| Situation | Use |
|---|---|
| The value may simply not exist | return Option[T] |
| Input may be malformed; the caller decides what to do | return Result[T, E], pass it along with ? |
| Several fallible steps, one fallback | attempt inside try … catch Err(e) |
| The operating system or the environment fails | let the IoError propagate; catch it where you can report it |
| A caller broke a rule (a bad argument) | throw ValueError(…) or your own exception class |
| "This cannot happen" | throw RuntimeError(…), or unreachable |
A Worked Example: Reading an Inventory File
This script parses lines of name, quantity, price, skipping blank lines and comments. Each line is parsed with ?; the line number is attached where the lines are counted; the top level decides what to print.
@mode(host.scripted)
from system.text import (parseInt, parseFloat);
struct Item {
name: string;
quantity: int;
price: float;
}
struct LineError {
line: int;
reason: string;
}
## One `name, quantity, price` line.
func parseLine(text: string): Result[Item, string] {
let fields = text.split(",");
if (fields.length() != 3) {
return Err(i"expected 3 fields, got {$ fields.length() $}");
}
let quantity = parseInt(fields[1].trim())?;
let price = parseFloat(fields[2].trim())?;
return Ok(Item(name = fields[0].trim(), quantity = quantity, price = price));
}
## Every item, or the first line that is wrong.
func parseInventory(text: string): Result[list[Item], LineError] {
var items: list[Item] = [];
var lineNo = 0;
for (line in text.split("\n")) {
lineNo = lineNo + 1;
let trimmed = line.trim();
if (trimmed.length() == 0 or trimmed.startsWith("#")) {
continue;
}
match parseLine(trimmed) {
Ok(item) => items.push(item),
Err(reason) => {
return Err(LineError(line = lineNo, reason = reason));
},
}
}
return Ok(items);
}
func report(text: string): void {
match parseInventory(text) {
Ok(items) => {
var value = 0.0;
for (item in items) {
value = value + item.price * (item.quantity as float);
}
println(i"{$ items.length() $} items, stock value {$ value $}");
},
Err(e) => println(i"line {$ e.line $}: {$ e.reason $}"),
}
}
report("""
# name, quantity, unit price
bolt, 100, 0.25
nut, 250, 0.12
washer, 80, 0.05
""");
report("""
bolt, 100, 0.25
nut, lots, 0.12
""");
report("bolt, 100");
Listing 10-9: Result, ? and context in one small program
3 items, stock value 59
line 2: invalid integer: lots
line 1: expected 3 fields, got 2
In a real script the text would come from readText(path), and the IoError for a missing file would be left to propagate to the top, where the uncaught-exception report says exactly what went wrong. (The value 59.0 prints as 59: Loup prints a whole-number float without a decimal point.)
Summary
Option[T](Some/None) is for absence; usematch,?.,??, andunwrap()only where absence is a bug.Result[T, E](Ok/Err) is for expected failure. Its methods are snake_case in 1.25:is_ok,unwrap_or,map_err. A camelCase name on aResultcompiles and fails at run time.expr?returns anErrfrom the enclosingResult-returning function, converting it throughFrom[E]when the types differ. Do not use?on anOption: 1.25 compiles it and producesErr(None).attempt exprsends anErrto the enclosingtry'scatch Err(e)arm.- Exceptions:
throw,catch (Type as e),catch (A | B as e),finally,throw … from e. Custom exceptions extendException.IoError(withcode) is the file-system exception;FileNotFoundErrorandIOErrordo not exist. - An uncaught exception prints
Uncaught Class: messageand a stack trace and exits with status 1. panicis not available in hosted code; throw instead.deferdoes not run yet; usefinallyorwith.
Try It Yourself
- Write
parseDuration(text: string): Result[int, string]that accepts"90s","5m"and"2h"and returns seconds. UseparseIntand?, and test it on good and bad input. - Write a script that reads a file named on the command line (
args()[0]) and prints its line count. CatchIoErrorand print a one-line message instead of the trace when the file is missing. - Rewrite Listing 10-9 so
parseInventoryreturnsResult[list[Item], AppError]andAppErrorimplementsFrom[LineError]. Whichmatchcan you replace with?? - Write
throw "oops";and thencatch (e: Exception), and read both diagnostics.