The Loup Web Book — contents
  1. Chapter 1: Getting Started
  2. Chapter 2: The Web Modes
  3. Chapter 3: Variables and Data Types
  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 Objects
  10. Chapter 10: Interfaces
  11. Chapter 11: Generics
  12. Chapter 12: Error Handling
  13. Chapter 13: Modules and Packages
  14. Chapter 14: Template Fences — HTML and CSS
  15. Chapter 15: Async and Concurrency
  16. Chapter 16: Reactive UI
  17. Chapter 17: Web Components
  18. Chapter 18: DOM, Storage, Workers, and JavaScript Interop
  19. Chapter 19: SSR, Hydration, and Deployment
  20. Chapter 20: Memory Safety in Web Apps

Chapter 12: Error Handling

What you'll learn. Loup's two error mechanisms, exceptions and Result[T, E]; how to throw, catch, chain, and define exceptions; what an uncaught exception prints; how to handle and propagate a Result with match, ?, and attempt; how to guarantee cleanup; and how errors behave in async code on the current toolchain.

Why it matters. Most bugs that reach users are error paths nobody exercised: a form field that parses to 0 instead of failing, a spinner that never hides, a network error that escapes into an "Uncaught" message. Loup gives you explicit tools for each case, and this chapter shows the mistakes you are likely to make with them and what the compiler or runtime says when you do.

Loup has two ways to report failure:

  • Exceptions are thrown with throw and caught with try/catch. An exception nobody catches unwinds the whole program. Use them for failures the immediate caller usually cannot fix: a broken invariant, an index out of range, a server that is down.
  • Result[T, E] is an ordinary enum, Ok(value) or Err(error), returned like any other value. Use it for failures the caller is expected to handle: invalid user input, a missing field, a value out of range.

Both are available in both web modes. The examples use @mode(web.compiled), where loup run calls main().

Throwing and catching

Listing 12-1 models a bank account whose withdraw method throws two kinds of exception, and a caller that handles each.

@mode(web.compiled)

class InsufficientFundsError : Exception {
    pub let requested: int;
    pub let available: int;

    new(requested: int, available: int) {
        super(i"insufficient funds: requested {$ requested $}, available {$ available $}");
        this.requested = requested;
        this.available = available;
    }
}

class Account {
    pub var balance: int;

    new(balance: int) {
        this.balance = balance;
    }

    pub modifier func withdraw(amount: int): void {
        if (amount <= 0) {
            throw ValueError(i"amount must be positive, got {$ amount $}");
        }
        if (amount > this.balance) {
            throw InsufficientFundsError(amount, this.balance);
        }
        this.balance = this.balance - amount;
    }
}

func tryWithdraw(edits account: Account, amount: int): string {
    try {
        safe { account.withdraw(amount); }
        return i"ok, balance is now {$ account.balance $}";
    } catch (InsufficientFundsError as e) {
        return i"short by {$ e.requested - e.available $}";
    } catch (ValueError as e) {
        return i"bad input: {$ e.message $}";
    } finally {
        println(i"attempted to withdraw {$ amount $}");
    }
}

func main(): void {
    let acct = Account(100);
    println(tryWithdraw(edits: acct, amount = 30));
    println(tryWithdraw(edits: acct, amount = 500));
    println(tryWithdraw(edits: acct, amount = -5));
}

Listing 12-1: Throwing, catching by type, and a finally block

attempted to withdraw 30
ok, balance is now 70
attempted to withdraw 500
short by 430
attempted to withdraw -5
bad input: amount must be positive, got -5

The pieces:

  • throw ValueError("…") constructs an exception and unwinds until a matching catch. You can only throw values whose class extends Exception (or BaseException); throw "disk full" is rejected with E_THROW_NON_ERROR.
  • catch (InsufficientFundsError as e) names the type first and the binding second. Clauses are tried top to bottom, and a clause matches subclasses too, so put the most specific type first. One clause can name several types: catch (IndexError | KeyError as e).
  • finally runs on every exit from the try: normal completion, a return inside the try, a caught exception, or one that keeps propagating. The finally output appears before each result above because it runs before tryWithdraw actually returns.
  • Every exception has a message: string, a cause: Option[Any], and a stackTrace (Language Reference §1-18.1).

Problem: the catch clause in TypeScript or Kotlin order

If you write the binding first, as in TypeScript's catch (e: Error), the parser stops at the colon:

This code does not compile
@mode(web.compiled)

func readPort(raw: string): int {
    let n = raw.to_int();
    if (n <= 0 or n > 65535) {
        throw ValueError(i"port out of range: '{$ raw $}'");
    }
    return n;
}

func main(): void {
    try {
        println(readPort("99999"));
    } catch (e: ValueError) {
        println(e.message);
    }
}
main.lpweb:14:15: error[E0001]: expected RParen, found Colon
main.lpweb:14:15: error[E0002]: expected `{`
main.lpweb:14:15: error[E0001]: expected expression
main.lpweb:14:17: error[E0003]: expected `;`
main.lpweb:17:1: error[E0001]: expected expression

Only the first line matters; the rest is the parser losing its place. Loup's catch clause is catch (Type as name), like Python's except Type as name:

try {
    println(readPort("99999"));
} catch (ValueError as e) {
    println(e.message);
}
port out of range: '99999'

The built-in exceptions

These exception classes are built in and need no import (Language Reference §1-18.1). All of them extend Exception:

Class Raised by
ValueError your own code, for a value that is invalid for the operation
TypeError a failed runtime type check (as)
IndexError xs[i] with i out of range
KeyError d[key] with a missing key
UnwrapError unwrap() or expect() on None or Err
IntegerDivisionError integer /, //, or % by zero
RenderError an Err value reaching println, str, or an interpolation
IoError file and process operations (host code)
RuntimeError faults in the runtime environment; StackOverflowError extends it
TimeoutError, CancellationError timeouts and cancelled tasks

Some failures you might expect to throw do not, in Loup 1.25.1:

  • Integer overflow wraps. 9223372036854775807 + 1 evaluates to -9223372036854775808. The Language Reference specifies checked arithmetic (NumericOverflowError), but no lane implements it yet; check ranges yourself where it matters.
  • "abc".to_int() returns 0 rather than failing. Validate the characters before converting (the worked example at the end does).
  • A negative index counts from the end: [10, 20, 30][-1] is 30, not an IndexError.

There is no built-in IOError (it is IoError), FileNotFoundError, NetworkError, or ParseError. Declare the ones your program needs.

Problem: throwing an exception class that does not exist

A CSV-style record parser that throws ParseError, as it would in Python or Java:

This code does not compile
@mode(web.compiled)

func parseRecord(line: string): list[string] {
    let fields = line.split(",");
    if (fields.len() != 3) {
        throw ParseError(i"expected 3 fields, got {$ fields.len() $}");
    }
    return fields;
}

func main(): void {
    try {
        println(parseRecord("ada,36"));
    } catch (ParseError as e) {
        println(i"skipping line: {$ e.message $}");
    }
}
main.lpweb:6:15: error[E0001]: undefined identifier `ParseError`

Define it. A user exception extends Exception or one of the built-in classes; here ValueError is the natural parent, so a handler for ValueError also catches parse errors. A constructor that takes extra data passes the message up with super(message):

@mode(web.compiled)

class ParseError : ValueError {
    pub let line: int;

    new(message: string, line: int) {
        super(message);
        this.line = line;
    }
}

func parseRecord(text: string, line: int): list[string] {
    let fields = text.split(",");
    if (fields.len() != 3) {
        throw ParseError(i"expected 3 fields, got {$ fields.len() $}", line);
    }
    return fields;
}

func main(): void {
    let rows = ["ada,36,admin", "lin,41", "sam,29,editor"];
    var line = 1;
    for (row in rows) {
        try {
            println(parseRecord(row, line));
        } catch (ParseError as e) {
            println(i"line {$ e.line $}: {$ e.message $}");
        }
        line = line + 1;
    }
}

Listing 12-2: A user-defined exception with an extra field

["ada", "36", "admin"]
line 2: expected 3 fields, got 2
["sam", "29", "editor"]

Uncaught exceptions

Problem: reaching for panic

Programmers coming from Rust or Go often write panic(...) for "this cannot happen":

This code does not compile
@mode(web.compiled)

func checkedIndex(xs: list[int], i: int): int {
    if (i < 0 or i >= xs.len()) {
        panic("index out of range");
    }
    return xs[i];
}

func main(): void {
    println(checkedIndex([10, 20, 30], 1));
}
main.lpweb:5:9: error[E0001]: undefined identifier `panic`

The Language Reference lists panic(msg) in the prelude of every mode, but the Loup 1.25.1 compiler does not define it for hosted or web code (only the freestanding toolchain has a panic path). Throw an exception instead. If nothing catches it, the program stops, which is what you wanted from panic:

This code throws at run time
@mode(web.compiled)

func checkedIndex(xs: list[int], i: int): int {
    if (i < 0 or i >= xs.len()) {
        throw IndexError(i"index {$ i $} out of range for {$ xs.len() $} items");
    }
    return xs[i];
}

func main(): void {
    println(checkedIndex([10, 20, 30], 1));
    println(checkedIndex([10, 20, 30], 3));
}

Listing 12-3: An exception that nobody catches

20
Uncaught IndexError: index 3 out of range for 3 items
  at checkedIndex (main.lpweb:5:15)
  at main (main.lpweb:12:13)

The first call prints 20. The second throws, nothing catches it, and the runner prints Uncaught, the class, the message, and one at function (file:line:column) line per frame, innermost first, on standard error. The exit status is 1. Read the trace bottom-up to see how the program got there: main called checkedIndex at line 12, which threw at line 5.

If you really mean "this point is impossible", Loup also has the unreachable expression; reaching it stops the program with Uncaught RuntimeError: panic: reached an `unreachable` expression, and unlike an exception it cannot be caught, not even by catch (Exception as e).

Chaining a cause

When you catch a low-level exception and throw a higher-level one, attach the original as the cause with from. The trace then shows both:

This code throws at run time
@mode(web.compiled)

class ConfigError : Exception {
    new(message: string) {
        super(message);
    }
}

func readPort(raw: string): int {
    let n = raw.to_int();
    if (n <= 0 or n > 65535) {
        throw ValueError(i"port out of range: '{$ raw $}'");
    }
    return n;
}

func loadConfig(raw: string): int {
    try {
        return readPort(raw);
    } catch (ValueError as e) {
        throw ConfigError("could not load config") from e;
    }
}

func main(): void {
    println(loadConfig("8080"));
    println(loadConfig("99999"));
}

Listing 12-4: throw … from e records the cause

8080
Uncaught ConfigError: could not load config
  at loadConfig (main.lpweb:21:15)
  at main (main.lpweb:27:13)

Caused by:
Uncaught ValueError: port out of range: '99999'
  at readPort (main.lpweb:12:15)
  at loadConfig (main.lpweb:19:16)
  at main (main.lpweb:27:13)

A handler can read the chain through e.cause, an Option.

Result[T, E]

For failures the caller should handle, return a Result. Ok, Err, Some, and None are built in. Listing 12-5 validates an email address and handles every outcome with match, which must cover both variants.

@mode(web.compiled)

func parseEmail(raw: string): Result[string, string] {
    let email = raw.trim().to_lowercase();
    if (not email.contains("@")) {
        return Err("missing '@'");
    }
    let parts = email.split("@");
    if (parts.len() != 2) {
        return Err("more than one '@'");
    }
    if (parts[0].len() == 0) {
        return Err("nothing before '@'");
    }
    if (not parts[1].contains(".")) {
        return Err(i"'{$ parts[1] $}' is not a domain");
    }
    return Ok(email);
}

func main(): void {
    for (raw in ["  Ada@Example.com ", "ada.example.com", "a@b@c.com", "@example.com", "ada@localhost"]) {
        match parseEmail(raw) {
            Ok(email) => println(i"ok:  {$ email $}"),
            Err(reason) => println(i"bad: {$ reason $}"),
        }
    }
    println(parseEmail("lin@example.org").is_ok());
    println(parseEmail("nope").unwrap_or("guest@example.org"));
    println(repr(parseEmail("x").map(func(e: string): int => e.len())));
}

Listing 12-5: Returning and matching a Result

ok:  ada@example.com
bad: missing '@'
bad: more than one '@'
bad: nothing before '@'
bad: 'localhost' is not a domain
true
guest@example.org
Err("missing '@'")

The methods that work on Result and Option in Loup 1.25.1 are spelled in snake_case:

Method On Result[T, E] On Option[T]
is_ok() / is_err() true for Ok / Err —
is_some() / is_none() — true for Some / None
unwrap() the value, or throws UnwrapError the value, or throws UnwrapError
expect(msg) like unwrap, with your message like unwrap, with your message
unwrap_or(fallback) the value, or fallback the value, or fallback
unwrap_err() the error —
map(f) transforms the Ok value transforms the Some value
map_err(f) transforms the Err value —
and_then(f) chains a function returning Result chains a function returning Option
filter(pred) — None unless pred holds

For an Option, value ?? fallback is a shorter unwrap_or.

Problem: the camelCase spelling from the Language Reference

The Language Reference writes these methods in camelCase (unwrapOr, isSome). Some of those spellings get through the checker and fail only when the line runs:

This code throws at run time
@mode(web.compiled)

func main(): void {
    let r: Result[int, string] = Err("timeout");
    println(r.unwrapOr(0));
}
Uncaught RuntimeError: internal interpreter limitation: unsupported instruction: CallVirtual unwrapOr on user class Result — method not found

"Internal interpreter limitation" means the program reached something the interpreter cannot execute; here, a method that does not exist. Use the snake_case name:

let r: Result[int, string] = Err("timeout");
println(r.unwrap_or(0));
0

Problem: printing a Result that might be an Err

Printing an Ok prints its value, so it is tempting to print a Result directly. An Err cannot be rendered for users; it throws:

This code throws at run time
@mode(web.compiled)

func main(): void {
    let r: Result[int, string] = Err("timeout");
    println(r);
}
Uncaught RenderError: rendered an `Err` value: Err("timeout")
  at main (main.lpweb:5:5)

This is deliberate (Language Reference §1-16.4): every text sink, including println, str, interpolation, and template fences, refuses to turn an unhandled error into text that would end up on a page. Handle the Err with match first, or use repr(r) when you want to look at the value while debugging; repr never throws.

let r: Result[int, string] = Err("timeout");
println(repr(r));
match r {
    Ok(n) => println(n),
    Err(reason) => println(i"failed: {$ reason $}"),
}
Err("timeout")
failed: timeout

Problem: a parse that never fails

to_int() returns 0 for text that is not a number. A quantity field validated only by range accepts garbage:

This code does not do what it should
@mode(web.compiled)

func parseQuantity(raw: string): Result[int, string] {
    let n = raw.to_int();
    if (n < 0 or n > 99) {
        return Err(i"{$ n $} is outside 0-99");
    }
    return Ok(n);
}

func main(): void {
    println(repr(parseQuantity("3")));
    println(repr(parseQuantity("ten")));
}
Ok(3)
Ok(0)

"ten" became an order for zero items. Check that the text is a number before converting:

@mode(web.compiled)

func parseQuantity(raw: string): Result[int, string] {
    if (raw.len() == 0) {
        return Err("empty");
    }
    for (c in raw.chars()) {
        if (c < "0" or c > "9") {
            return Err(i"'{$ raw $}' is not a whole number");
        }
    }
    let n = raw.to_int();
    if (n > 99) {
        return Err(i"{$ n $} is outside 0-99");
    }
    return Ok(n);
}

func main(): void {
    println(repr(parseQuantity("3")));
    println(repr(parseQuantity("ten")));
}

Listing 12-6: Validating digits before to_int()

Ok(3)
Err("'ten' is not a whole number")

Propagating with ?

Matching every Result by hand gets long when one function calls several fallible ones. The postfix ? operator unwraps an Ok and, on an Err, returns that Err from the enclosing function at once (Language Reference §1-5.7). It may only be used inside a function that itself returns a Result.

@mode(web.compiled)

func parseHost(raw: string): Result[string, string] {
    if (raw.len() == 0) {
        return Err("host is empty");
    }
    return Ok(raw);
}

func parsePort(raw: string): Result[int, string] {
    let n = raw.to_int();
    if (n <= 0 or n > 65535) {
        return Err(i"bad port '{$ raw $}'");
    }
    return Ok(n);
}

func parseAddress(text: string): Result[string, string] {
    let parts = text.split(":");
    if (parts.len() != 2) {
        return Err(i"expected host:port, got '{$ text $}'");
    }
    let host = parseHost(parts[0])?;
    let port = parsePort(parts[1])?;
    return Ok(i"{$ host $} on port {$ port $}");
}

func main(): void {
    for (text in ["api.example.com:443", "api.example.com", ":80", "localhost:http"]) {
        match parseAddress(text) {
            Ok(addr) => println(addr),
            Err(reason) => println(i"error: {$ reason $}"),
        }
    }
}

Listing 12-7: ? returns the first Err to the caller

api.example.com on port 443
error: expected host:port, got 'api.example.com'
error: host is empty
error: bad port 'http'

Problem: ? with a different error type

? returns the Err unchanged, so its error type must match the function's. Here parseAge fails with a string, but checkAge promises a FieldError:

This code does not compile
@mode(web.compiled)

enum FieldError {
    Missing(field: string),
    Invalid(field: string, reason: string),
}

func parseAge(raw: string): Result[int, string] {
    if (raw.len() == 0) {
        return Err("empty");
    }
    return Ok(raw.to_int());
}

func checkAge(raw: string): Result[int, FieldError] {
    let age = parseAge(raw)?;
    return Ok(age);
}

func main(): void {
    println(repr(checkAge("")));
}
main.lpweb:16:15: error[E_PROPAGATE_MISSING_FROM]: E_PROPAGATE_MISSING_FROM: '?' propagates an error of type 'string' out of a function whose error type is 'FieldError', which does not implement `From[string]` (§1-5.7)

The message names both types and the general mechanism: an error type that implements From[string] would be converted automatically. For a one-off conversion, convert the error yourself with map_err before applying ?:

func checkAge(raw: string): Result[int, FieldError] {
    let age = parseAge(raw).map_err(func(r: string): FieldError => FieldError.Invalid(field = "age", reason = r))?;
    return Ok(age);
}

func main(): void {
    match checkAge("") {
        Ok(age) => println(age),
        Err(FieldError.Missing(field)) => println(i"{$ field $} is missing"),
        Err(FieldError.Invalid(field, reason)) => println(i"{$ field $}: {$ reason $}"),
    }
}
age: empty

Problem: ? on an Option

? is for Result. The Language Reference makes ? on an Option a compile error (E_PROPAGATE_NOT_RESULT). Loup 1.25.1 accepts it inside a Result-returning function and produces an error value nobody asked for:

This code does not do what it should
@mode(web.compiled)

func findAge(ages: dict[string, int], name: string): Option[int] {
    if (ages.contains_key(name)) {
        return Some(ages[name]);
    }
    return None;
}

func ageNextYear(ages: dict[string, int], name: string): Result[int, string] {
    let age = findAge(ages, name)?;
    return Ok(age + 1);
}

func main(): void {
    let ages: dict[string, int] = {"ada": 36};
    println(repr(ageNextYear(ages, "ada")));
    println(repr(ageNextYear(ages, "bob")));
}
Ok(37)
Err(None)

The function promised a string error and returned Err(None). (Inside a function that returns Option, the same ? is rejected with E_PROPAGATE_WRONG_RETURN.) Say what a missing value means by matching on the Option:

func ageNextYear(ages: dict[string, int], name: string): Result[int, string] {
    let age = match findAge(ages, name) {
        Some(a) => a,
        None => {
            return Err(i"no age on file for {$ name $}");
        },
    };
    return Ok(age + 1);
}

func main(): void {
    let ages: dict[string, int] = {"ada": 36};
    println(repr(ageNextYear(ages, "ada")));
    println(repr(ageNextYear(ages, "bob")));
}
Ok(37)
Err("no age on file for bob")

attempt: handling a Result in a catch arm

? always returns from the function. Sometimes you want to recover in place instead, for example to fall back to a default. The attempt prefix operator unwraps an Ok; on an Err, it jumps to the catch Err(e) arm of the enclosing try (Language Reference §1-5.10):

@mode(web.compiled)

func parsePort(raw: string): Result[int, string] {
    let n = raw.to_int();
    if (n <= 0 or n > 65535) {
        return Err(i"bad port '{$ raw $}'");
    }
    return Ok(n);
}

func main(): void {
    var port = 0;
    try {
        port = attempt parsePort("80x");
    } catch Err(e) {
        println(i"using default: {$ e $}");
        port = 8080;
    }
    println(port);
}

Listing 12-8: attempt sends an Err to a catch Err arm

using default: bad port '80x'
8080

attempt needs that arm. Without an enclosing try, the compiler has nowhere to send the error:

This code does not compile
@mode(web.compiled)

func parsePort(raw: string): Result[int, string] {
    return Ok(raw.to_int());
}

func main(): void {
    let port = attempt parsePort("80");
    println(port);
}
main.lpweb:8:16: error[E_ATTEMPT_OUTSIDE_BLOCK]: 'attempt' requires an enclosing 'try' block with a 'catch Err(e)' arm

Wrap the code in try { … } catch Err(e) { … } as in Listing 12-8, or use ? if the function returns a Result and the caller should decide.

Converting between exceptions and Result

The two mechanisms meet at boundaries. To turn a Result into an exception, match and throw; to turn an exception into a Result, catch and return Err:

@mode(web.compiled)

func parsePort(raw: string): Result[int, string] {
    let n = raw.to_int();
    if (n <= 0 or n > 65535) {
        return Err(i"bad port '{$ raw $}'");
    }
    return Ok(n);
}

func requirePort(raw: string): int {
    return match parsePort(raw) {
        Ok(n) => n,
        Err(reason) => {
            throw ValueError(reason);
        },
    };
}

func safeLookup(rows: list[string], i: int): Result[string, string] {
    try {
        return Ok(rows[i]);
    } catch (IndexError as e) {
        return Err(e.message);
    }
}

func main(): void {
    println(requirePort("443"));
    try {
        println(requirePort("0"));
    } catch (ValueError as e) {
        println(i"thrown: {$ e.message $}");
    }
    println(repr(safeLookup(["a", "b"], 1)));
    println(repr(safeLookup(["a", "b"], 5)));
}

Listing 12-9: Crossing between Result and exceptions

443
thrown: bad port '0'
Ok("b")
Err("list index 5 out of range (len 2)")

A useful rule: validate input with Result close to where it arrives, and throw for infrastructure failures that most callers cannot do anything about. Whichever split you choose, be consistent within a module.

Cleanup that always runs

Loading indicators, open handles, and locks must be released however a function exits. finally does that:

@mode(web.compiled)

class Spinner {
    pub var visible: bool = false;

    pub modifier func show(): void {
        this.visible = true;
        println("spinner on");
    }

    pub modifier func hide(): void {
        this.visible = false;
        println("spinner off");
    }
}

safe func save(edits spinner: Spinner, payload: string): int {
    spinner.show();
    try {
        if (payload.len() == 0) {
            throw ValueError("nothing to save");
        }
        println(i"saved {$ payload.len() $} bytes");
        return payload.len();
    } finally {
        spinner.hide();
    }
}

func main(): void {
    let spinner = Spinner();
    safe {
        save(edits: spinner, payload = "hello");
        try {
            save(edits: spinner, payload = "");
        } catch (ValueError as e) {
            println(i"error: {$ e.message $}");
        }
    }
    println(spinner.visible);
}

Listing 12-10: finally hides the spinner on success and on failure

spinner on
saved 5 bytes
spinner off
spinner on
spinner off
error: nothing to save
false

Known gap: defer does not run yet

The Language Reference also has defer, which registers a statement or block to run when the enclosing block exits, by any path, in last-in-first-out order (§1-6.5). It is the tidier tool when a function acquires several resources. Loup 1.25.1 parses defer but never runs the deferred code:

This code does not do what it should
@mode(web.compiled)

func work(fail: bool): void {
    println("open");
    defer {
        println("close");
    }
    if (fail) {
        throw ValueError("boom");
    }
    println("work done");
}

func main(): void {
    work(false);
    try {
        work(true);
    } catch (ValueError as e) {
        println(e.message);
    }
}
open
work done
open
boom

close should appear after work done and before boom; it never appears. Until this is fixed, write cleanup with try/finally as in Listing 12-10.

Errors in async code

try/catch, finally, and ? work in async functions as in ordinary ones. To await at all, the caller must itself be async; that includes main:

This code does not compile
@mode(web.compiled)

async func fetchStatus(path: string): Task[int] {
    if (path == "/missing") {
        throw ValueError("404 not found");
    }
    return 200;
}

func main(): void {
    println(await fetchStatus("/"));
}
main.lpweb:11:13: error[E_AWAIT_NOT_ASYNC]: E_AWAIT_NOT_ASYNC: `await` is only valid inside an `async func` body, an entry script's top level, or the REPL (§2-1.1)

Declare async func main(): Task[void]; the runner waits for it. Listing 12-11 catches a failure from an awaited call and propagates a Result with ? through an async function.

@mode(web.compiled)

class HttpError : Exception {
    pub let status: int;

    new(status: int) {
        super(i"server answered {$ status $}");
        this.status = status;
    }
}

async func fetchStatus(path: string): Task[int] {
    if (path == "/missing") {
        throw HttpError(404);
    }
    return 200;
}

async func loadPage(path: string): Task[string] {
    try {
        let status = await fetchStatus(path);
        return i"loaded {$ path $} ({$ status $})";
    } catch (HttpError as e) {
        return i"fallback page: {$ e.message $}";
    }
}

async func parseCount(raw: string): Task[Result[int, string]] {
    if (raw.len() == 0) {
        return Err("empty input");
    }
    return Ok(raw.to_int());
}

async func doubled(raw: string): Result[int, string] {
    let n = (await parseCount(raw))?;
    return Ok(n * 2);
}

async func main(): Task[void] {
    println(await loadPage("/home"));
    println(await loadPage("/missing"));
    println(repr(await doubled("21")));
    println(repr(await doubled("")));
}

Listing 12-11: Exceptions and ? in async functions

loaded /home (200)
fallback page: server answered 404
Ok(42)
Err("empty input")

Two things about the current toolchain:

  • doubled declares its return type as Result[int, string]; an async func with a plain return type means the same as Task[Result[int, string]] (Language Reference §2-1.1). Use that form when the body uses ?: Loup 1.25.1 rejects ? in a function spelled : Task[Result[…]] with E_PROPAGATE_WRONG_RETURN, although the Language Reference allows it.
  • On the 1.25.1 interpreter, async code runs eagerly: calling an async function runs its whole body before the call returns, and there is no scheduler (Chapter 15). That changes where a failure surfaces, as the next problem shows.

Problem: the task is created outside the try

The Language Reference says that a failed task rethrows its failure at await. Code that starts a task early and awaits it inside a try relies on that:

This code throws at run time
@mode(web.compiled)

async func fetchStatus(path: string): Task[int] {
    println(i"fetching {$ path $}");
    if (path == "/missing") {
        throw ValueError("404");
    }
    return 200;
}

async func main(): Task[void] {
    let task = fetchStatus("/missing");
    println("task created");
    try {
        let status = await task;
        println(status);
    } catch (ValueError as e) {
        println(i"caught at await: {$ e.message $}");
    }
}
fetching /missing
Uncaught ValueError: 404
  at fetchStatus (main.lpweb:6:15)
  at main (main.lpweb:12:16)

Because the body ran during the call on line 12, the exception escaped there, outside the try, and task created was never printed. Put the call inside the try together with its await, as loadPage does in Listing 12-11; that code is correct both today and once a real scheduler arrives:

async func main(): Task[void] {
    try {
        let status = await fetchStatus("/missing");
        println(status);
    } catch (ValueError as e) {
        println(i"caught: {$ e.message $}");
    }
}
fetching /missing
caught: 404

⚠ Planned. The Language Reference also describes how errors cross the browser boundaries: exceptions from Web Workers arriving as failed tasks, JavaScript exceptions surfacing as JsException, and Loup exceptions thrown out of @jsexport functions becoming JavaScript Errors. None of the browser runtime exists in Loup 1.25.1; Chapter 18 describes the design.

A worked example: a signup form

Listing 12-12 validates a signup form and then calls a (simulated) server. Validation failures are expected, so they are a Result with an enum of reasons the page can turn into messages. The server call can fail for reasons the form cannot fix, so it throws.

@mode(web.compiled)

struct SignupForm {
    email: string;
    age: string;
    agreed: bool;
}

struct Signup {
    email: string;
    age: int;
}

enum FieldError {
    Missing(field: string),
    Invalid(field: string, reason: string),
    NotAgreed,
}

class HttpError : Exception {
    pub let status: int;

    new(status: int) {
        super(i"server answered {$ status $}");
        this.status = status;
    }
}

func parseEmail(raw: string): Result[string, string] {
    let email = raw.trim().to_lowercase();
    let parts = email.split("@");
    if (parts.len() != 2 or parts[0].len() == 0) {
        return Err("expected one name before '@'");
    }
    if (not parts[1].contains(".")) {
        return Err(i"'{$ parts[1] $}' is not a domain");
    }
    return Ok(email);
}

func parseAge(raw: string): Result[int, string] {
    if (raw.len() == 0) {
        return Err("empty");
    }
    for (c in raw.chars()) {
        if (c < "0" or c > "9") {
            return Err(i"'{$ raw $}' is not a whole number");
        }
    }
    let age = raw.to_int();
    if (age < 13 or age > 120) {
        return Err(i"{$ age $} is outside 13-120");
    }
    return Ok(age);
}

func validate(form: SignupForm): Result[Signup, FieldError] {
    if (form.email.len() == 0) {
        return Err(FieldError.Missing(field = "email"));
    }
    if (not form.agreed) {
        return Err(FieldError.NotAgreed);
    }
    let email = parseEmail(form.email).map_err(func(r: string): FieldError => FieldError.Invalid(field = "email", reason = r))?;
    let age = parseAge(form.age).map_err(func(r: string): FieldError => FieldError.Invalid(field = "age", reason = r))?;
    return Ok(Signup(email = email, age = age));
}

func message(err: FieldError): string {
    return match err {
        FieldError.Missing(field) => i"please fill in {$ field $}",
        FieldError.Invalid(field, reason) => i"{$ field $}: {$ reason $}",
        FieldError.NotAgreed => "please accept the terms",
    };
}

func createAccount(signup: Signup): int {
    if (signup.email.ends_with("@busy.example")) {
        throw HttpError(503);
    }
    return 1000 + signup.age;
}

func submit(form: SignupForm): string {
    match validate(form) {
        Err(err) => {
            return message(err);
        },
        Ok(signup) => {
            try {
                let id = createAccount(signup);
                return i"created account {$ id $}";
            } catch (HttpError as e) {
                return i"try again later ({$ e.message $})";
            }
        },
    }
}

func main(): void {
    println(submit(SignupForm(email = "", age = "30", agreed = true)));
    println(submit(SignupForm(email = "ada@example.com", age = "30", agreed = false)));
    println(submit(SignupForm(email = "ada@localhost", age = "30", agreed = true)));
    println(submit(SignupForm(email = "ada@example.com", age = "thirty", agreed = true)));
    println(submit(SignupForm(email = "lin@busy.example", age = "41", agreed = true)));
    println(submit(SignupForm(email = " Ada@Example.com", age = "36", agreed = true)));
}

Listing 12-12: Expected failures as Result, infrastructure failures as exceptions

please fill in email
please accept the terms
email: 'localhost' is not a domain
age: 'thirty' is not a whole number
try again later (server answered 503)
created account 1036

validate reads top to bottom: each ? either produces a clean value or ends the function with a FieldError. message maps every reason to text, and because match must be exhaustive, adding a new FieldError variant later forces you to write its message. The try in submit is the only place that knows about the server.

Try it yourself

  1. Add a TooYoung(minimum: int) variant to FieldError, return it from validate instead of the range error, and see which line the compiler sends you to.
  2. Give HttpError a retryable(): bool method (true for 502, 503, and 504) and make submit print a different message for retryable failures.
  3. Rewrite Spinner and save from Listing 12-10 so that save shows two indicators and hides both in one finally.

Summary

  • Throw with throw SomeError("…"); catch with catch (Type as e), most specific first; finally always runs. User exceptions extend Exception (or a built-in such as ValueError) and pass the message with super(message).
  • An uncaught exception prints Uncaught Type: message with at function (file:line:col) frames and exits with status 1. throw … from e adds a Caused by: section.
  • panic is not available in hosted or web code in Loup 1.25.1; throw instead. IOError, FileNotFoundError, and ParseError are not built in.
  • Result[T, E] is for expected failures: match it, or use the snake_case helpers (unwrap_or, map_err, …). Printing an Err throws RenderError; use repr to inspect it.
  • ? returns an Err from a Result-returning function; convert error types with map_err. Use attempt to send an Err to a catch Err(e) arm instead.
  • Known gaps in 1.25.1: integer overflow wraps, to_int() returns 0 on bad input, ? on an Option yields Err(None), defer never runs (use finally), and async bodies run eagerly, so make each call and its await inside the same try.