The Loup Scripting Book — contents
  1. Chapter 1: Getting Started
  2. Chapter 2: Variables and Data Types
  3. Chapter 3: Functions and Closures
  4. Chapter 4: Control Flow
  5. Chapter 5: Collections
  6. Chapter 6: Structs
  7. Chapter 7: Enums and Pattern Matching
  8. Chapter 8: Classes and Objects
  9. Chapter 9: Interfaces and Generics
  10. Chapter 10: Error Handling
  11. Chapter 11: Modules and Packages
  12. Chapter 12: Strings, Interpolation, and Template Fences
  13. Chapter 13: Concurrency
  14. Chapter 14: Reflection and Dynamic Compilation
  15. Chapter 15: Reactive UI
  16. Chapter 16: Testing
  17. Chapter 17: JavaScript Host Interop
  18. Chapter 18: Building Script Tools
  19. Chapter 19: Memory Safety and Resource Management

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
  • match forces you to say what happens in both cases.
  • x?.field is None when x is None and Some(x.field) otherwise.
  • x ?? fallback unwraps the Option, or evaluates fallback when it is None.
  • unwrap() returns the value or throws UnwrapError; expect("message") does the same with your message. Use them only where None would 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:

This code does not compile
@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:

This code throws at run time
@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:

This code does not do what it should
@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:

This code does not compile
@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:

This code does not do what it should
@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:

This code does not compile
@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:

This code does not compile
@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 to name.
  • Every exception has a message. IoError also has code, the operating system's error number (2 is "no such file").
  • finally runs whether the try block finished, threw, or returned. A return, break or continue inside finally is a compile error (E_FINALLY_CONTROL).
  • catch clauses are tried top to bottom, so put specific types first and catch (Exception as e) last. A bare catch { … } handles any Exception.

Mistake: catch (e: Type)

TypeScript, Kotlin and Swift habits produce this:

This code does not compile
@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:

This code does not compile
@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

This code does not compile
@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:

This code throws at run time
@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:

This code throws at run time
@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 catch block without from automatically gets the caught exception as its cause. The 1.25 toolchain does not do this — e.cause is None — so write from e whenever 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:

This code does not compile
@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 run action when the enclosing block exits. The 1.25 toolchain accepts defer but loup run never executes the deferred action. Until it does, use try/finally or with for 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; use match, ?., ??, and unwrap() 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 a Result compiles and fails at run time.
  • expr? returns an Err from the enclosing Result-returning function, converting it through From[E] when the types differ. Do not use ? on an Option: 1.25 compiles it and produces Err(None).
  • attempt expr sends an Err to the enclosing try's catch Err(e) arm.
  • Exceptions: throw, catch (Type as e), catch (A | B as e), finally, throw … from e. Custom exceptions extend Exception. IoError (with code) is the file-system exception; FileNotFoundError and IOError do not exist.
  • An uncaught exception prints Uncaught Class: message and a stack trace and exits with status 1.
  • panic is not available in hosted code; throw instead. defer does not run yet; use finally or with.

Try It Yourself

  1. Write parseDuration(text: string): Result[int, string] that accepts "90s", "5m" and "2h" and returns seconds. Use parseInt and ?, and test it on good and bad input.
  2. Write a script that reads a file named on the command line (args()[0]) and prints its line count. Catch IoError and print a one-line message instead of the trace when the file is missing.
  3. Rewrite Listing 10-9 so parseInventory returns Result[list[Item], AppError] and AppError implements From[LineError]. Which match can you replace with ??
  4. Write throw "oops"; and then catch (e: Exception), and read both diagnostics.