The Loup Native Book — contents
  1. Chapter 1: Getting started
  2. Chapter 2: Native mode overview
  3. Chapter 3: Variables and numerics
  4. Chapter 4: Functions
  5. Chapter 5: Control flow
  6. Chapter 6: Collections
  7. Chapter 7: Structs
  8. Chapter 8: Enums and pattern matching
  9. Chapter 9: Classes and ARC
  10. Chapter 10: Interfaces
  11. Chapter 11: Generics
  12. Chapter 12: Error handling
  13. Chapter 13: Modules and packages
  14. Chapter 14: Template fences
  15. Chapter 15: Ownership verbs
  16. Chapter 16: borrow, excl borrow and noescape
  17. Chapter 17: unique ownership
  18. Chapter 18: Lifetimes and regions
  19. Chapter 19: @unsafe and calling C
  20. Chapter 20: Concurrency with OS threads
  21. Chapter 21: Async on native
  22. Chapter 22: Desktop UI
  23. Chapter 23: SIMD and GPU
  24. Chapter 24: Building and distributing
  25. Chapter 25: Memory safety patterns

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

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

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

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

This code does not do what it should
@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 Result from 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 e for exceptions, a descriptive error value for Results.
  • Do not print a Result directly; match on it (chapter 8).

Summary

  • throw X(...) raises; catch (Type as e), catch (A | B as e) and catch (Exception as e) handle; finally always runs; throw e; re-throws.
  • User exceptions extend Exception and call super(message). Built-ins include ValueError, IndexError, KeyError, IoError (not IOError), NumericOverflowError and UnwrapError; there is no FileNotFoundError.
  • throw New(...) from e chains a cause, shown as Caused by: and readable as e.cause.
  • loup run prints a stack trace for an uncaught exception; a native binary prints one Class: message line. Both exit with status 1.
  • string.toInt() returns 0 for bad input in 1.25.1; validate first.
  • Result is for expected failures; attempt hands an Err to a catch Err(e) clause.
  • Chapter 13 covers modules and packages.