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 8: Enums and pattern matching

An enum is a type whose values are one of a fixed set of variants, each of which may carry its own data. Together with match, enums are how Loup models "one of several kinds of thing": a parsed command, a connection state, a value that may be missing (Option), or an operation that may fail (Result). This chapter covers declaring and matching enums, Option and Result, and union types.

Declaring and matching an enum

Variants are separated by commas. A variant may have a payload, declared like named parameters. Construct a variant with its enum's name, a dot, and the payload arguments, by name or by position:

@mode(host.native)

enum Shape {
    Circle(radius: float),
    Rectangle(width: float, height: float),
    Point,
}

func area(shape: Shape): float {
    return match shape {
        Shape.Circle(r) => 3.14159 * r * r,
        Shape.Rectangle(w, h) => w * h,
        Shape.Point => 0.0,
    };
}

func main(): void {
    let shapes = [Shape.Circle(radius = 1.0), Shape.Rectangle(width = 2.0, height = 3.0), Shape.Point];
    for (s in shapes) {
        println(area(s));
    }
    println(shapes[0]);
    println(Shape.Circle(1.0) == Shape.Circle(radius = 1.0));
}

Listing 8-1: An enum with payloads

3.14159
6
0
Circle(1.0)
true

In a match arm, Shape.Circle(r) matches a circle and binds its payload, by position, to r. Enum values compare structurally: two values are equal when they are the same variant with equal payloads. println renders an enum value as its variant and payload; a native build in 1.25.1 refuses to render one whose payload is a tuple or a Result (or a deeper nesting such as Option[list[…]]) rather than print a pointer, so match on such values and print the parts.

Patterns nest, and a payload position may hold a literal, a binding, or _:

enum Event {
    Click(x: int, y: int, button: int),
    Key(code: int),
}

func describe(e: Event): string {
    return match e {
        Event.Click(0, 0, _) => "click at the origin",
        Event.Click(x, y, 1) => i"left click at {$ x $},{$ y $}",
        Event.Click(_, _, _) => "other click",
        Event.Key(27) => "escape",
        Event.Key(code) => i"key {$ code $}",
    };
}

func main(): void {
    println(describe(Event.Click(x = 0, y = 0, button = 1)));
    println(describe(Event.Click(x = 5, y = 7, button = 1)));
    println(describe(Event.Click(x = 5, y = 7, button = 3)));
    println(describe(Event.Key(code = 27)));
    println(describe(Event.Key(code = 65)));
}

Listing 8-2: Literal patterns inside a variant

click at the origin
left click at 5,7
other click
escape
key 65

Arms are tried in order, so put the specific ones first. An arm may also carry a guard, Event.Key(code) if (code < 32) => …, which is checked after the pattern matches.

Exhaustiveness

A match on an enum must handle every variant. When you add a variant, every match that does not handle it stops compiling, which is how a refactoring finds all the places it has to touch:

This code does not compile
@mode(host.native)

enum Shape {
    Circle(radius: float),
    Rectangle(width: float, height: float),
    Triangle(base: float, height: float),
}

func area(shape: Shape): float {
    return match shape {
        Shape.Circle(r) => 3.14159 * r * r,
        Shape.Rectangle(w, h) => w * h,
    };
}

func main(): void {
    println(area(Shape.Triangle(base = 4.0, height = 3.0)));
}
main.lphc:10:12: error[E0001]: Non-exhaustive match: missing variants Triangle

Add the arm, Shape.Triangle(b, h) => b * h / 2.0. A _ => … arm also satisfies the check, but then the compiler can no longer tell you about the next variant you add, so prefer listing the variants.

⚠ Planned. Several pattern forms from the Language Reference do not parse in 1.25.1: alternatives (Key(1) | Key(2) =>), ranges (0x20..0x7E =>), whole-value bindings (Event.Click(_, _, _) @ c =>), and struct patterns (chapter 7). Use guards, and bind the matched value before the match if an arm needs all of it.

A command parser

Enums shine when each kind of input carries different data. Here a line of text becomes one of three commands, and each command is applied to a key-value store:

@mode(host.native)

enum Command {
    Get(key: string),
    Set(key: string, value: string),
    Delete(key: string),
}

func parse(line: string): Result[Command, string] {
    let parts = line.split(" ");
    let verb = parts[0];
    if (verb == "get" and parts.length() == 2) {
        return Ok(Command.Get(key = parts[1]));
    }
    if (verb == "set" and parts.length() == 3) {
        return Ok(Command.Set(key = parts[1], value = parts[2]));
    }
    if (verb == "del" and parts.length() == 2) {
        return Ok(Command.Delete(key = parts[1]));
    }
    return Err(i"cannot parse: {$ line $}");
}

func apply(edits store: dict[string, string], cmd: Command): string {
    return match cmd {
        Command.Get(key) => store.get(key) ?? "(not set)",
        Command.Set(key, value) => {
            store[key] = value;
            "OK"
        },
        Command.Delete(key) => {
            store.remove(key);
            "OK"
        },
    };
}

func main(): void {
    var store: dict[string, string] = {};
    let script = ["set region eu-west", "get region", "del region", "get region", "frobnicate"];
    for (line in script) {
        match parse(line) {
            Ok(cmd) => println(apply(edits: store, cmd)),
            Err(message) => println(i"error: {$ message $}"),
        }
    }
}

Listing 8-3: Parsing text into an enum and dispatching on it

OK
eu-west
OK
(not set)
error: cannot parse: frobnicate

An arm's body can be a block; its last expression is the arm's value, as with if expressions. parse returns a Result, covered next.

Option[T]

Option[T] is a built-in enum with two variants, Some(value) and None. It is how Loup says "maybe a value", where C uses a null pointer and Python uses None. T? is shorthand for Option[T]:

@mode(host.native)

class User {
    pub let name: string;
    pub let manager: User?;
    new(name: string, manager: User?) {
        this.name = name;
        this.manager = manager;
    }
}

func findUser(users: list[User], name: string): User? {
    for (u in users) {
        if (u.name == name) {
            return Some(u);
        }
    }
    return None;
}

func main(): void {
    let ada = User("ada", None);
    let bob = User("bob", Some(ada));
    let staff = [ada, bob];

    let found = findUser(staff, "bob");
    println(found.is_some());
    println(bob.manager?.name ?? "nobody");
    println(ada.manager?.name ?? "nobody");
    let eve = findUser(staff, "eve");
    println(eve?.name ?? "no such user");

    match findUser(staff, "ada") {
        Some(u) => println(i"found {$ u.name $}"),
        None => println("not found"),
    }
}

Listing 8-4: Working with Option

true
ada
nobody
no such user
found ada

a?.b reads b if a is Some, and is None otherwise; x ?? fallback takes the value out of an Option or uses the fallback. Together they replace chains of null checks. (In 1.25.1 a second ?. in a chain, as in found?.manager?.name, does not type-check yet; go one level at a time with a match or ??.) The methods on Option are is_some(), is_none(), unwrap(), expect(message) and unwrap_or(fallback).

unwrap() asserts that the value is present. When it is not, the program stops:

This code throws at run time
@mode(host.native)

func main(): void {
    let ports: dict[string, int] = {"http": 80};
    let ssh = ports.get("ssh").unwrap();
    println(ssh);
}
Uncaught UnwrapError: called `unwrap()` on a None value
  at main (main.lphc:5:15)

Use unwrap() only where None would be a bug; for input that may legitimately be missing, use ??, unwrap_or, or a match.

Mistake: the method names from the specification

The Language Reference spells these methods in camel case (isSome, unwrapOr). The toolchain implements the snake-case names, and the camel-case ones are errors:

This code does not compile
@mode(host.native)

func main(): void {
    let timeout: int? = None;
    println(timeout.unwrapOr(30));
}
main.lphc:5:13: error[E0001]: type 'Option[int]' has no method 'unwrapOr'

Write timeout.unwrap_or(30). On a Result the same mistake is worse: it compiles and fails only when the line runs (see below).

Result[T, E]

Result[T, E] is a built-in enum with Ok(value) and Err(error). Return it from a function whose failure is an expected outcome the caller should handle, such as parsing input. Its methods are is_ok(), is_err(), unwrap(), unwrap_err(), expect(message) and unwrap_or(fallback).

Inside a function that itself returns a Result, the postfix ? operator unwraps an Ok or returns the Err to the caller:

@mode(host.native)

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 parseAddress(text: string): Result[string, string] {
    let parts = text.split(":");
    if (parts.length() != 2) {
        return Err(i"expected host:port, got {$ text $}");
    }
    let port = parsePort(parts[1])?;
    return Ok(i"{$ parts[0] $} port {$ port $}");
}

func main(): void {
    for (input in ["db:5432", "db:99999", "db"]) {
        match parseAddress(input) {
            Ok(addr) => println(addr),
            Err(e) => println(i"error: {$ e $}"),
        }
    }
}

Listing 8-5: Propagating errors with ?

db port 5432
error: bad port: 99999
error: expected host:port, got db

Chapter 12 compares Result with exceptions and covers attempt, which converts between them.

Mistake: printing a Result

Printing an Option or an Ok prints the value inside. Printing an Err is an error at run time, so code that "just prints the result" works in testing and fails on the first bad input:

This code throws at run time
@mode(host.native)

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 {
    println(parsePort("8080"));
    println(parsePort("99999"));
}
8080
Uncaught RenderError: rendered an `Err` value: Err("bad port: 99999")
  at main (main.lphc:13:5)

match on the result, as in Listing 8-5, so the error case is handled on purpose.

Mistake: unwrapOr on a Result

This code throws at run time
@mode(host.native)

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

Unlike on Option, the checker lets the camel-case name through on a Result, and the call fails when it runs. Write retries.unwrap_or(3).

Mistake: ? on an Option inside a Result function

The specification says ? works only on a Result (E_PROPAGATE_NOT_RESULT). Stage0 1.25.1 accepts it on an Option inside a function that returns Result, and turns a None into an Err whose payload is None, not the error message the caller expects:

This code does not do what it should
@mode(host.native)

func portFrom(env: dict[string, string]): Result[int, string] {
    let raw = env.get("PORT")?;
    return Ok(raw.toInt());
}

func main(): void {
    match portFrom({"HOST": "db"}) {
        Ok(n) => println(i"port {$ n $}"),
        Err(e) => println(i"error: [{$ e $}]"),
    }
}
error: []

The error message is empty. Turn the missing value into a real error yourself:

func portFrom(env: dict[string, string]): Result[int, string] {
    match env.get("PORT") {
        Some(raw) => { return Ok(raw.toInt()); },
        None => { return Err("PORT is not set"); },
    }
}

func main(): void {
    match portFrom({"HOST": "db"}) {
        Ok(n) => println(i"port {$ n $}"),
        Err(e) => println(i"error: [{$ e $}]"),
    }
}

Listing 8-6: Converting a missing value into an error

error: [PORT is not set]

In a function that returns Option, ? is refused outright: E_PROPAGATE_WRONG_RETURN: the '?' operator can only be used inside a function returning Result.

Union types

A union type such as string | int holds a value of any of its member types without declaring an enum (Language Reference §1-4.5). A match with type patterns, or an is test, narrows it:

func display(value: string | int | bool): string {
    return match value {
        string(s) => i"text: {$ s $}",
        int(n) => i"number: {$ n $}",
        bool(b) => if (b) { "yes" } else { "no" },
    };
}

func increment(value: string | int): string {
    if (value is string) {
        return value.toUpperCase();
    } else {
        return str(value + 1);
    }
}

func main(): void {
    println(display("hi"));
    println(display(4));
    println(display(true));
    println(increment("abc"));
    println(increment(41));
}

Listing 8-7: Narrowing a union

text: hi
number: 4
yes
ABC
42

Narrowing applies inside the branches of the if; code after an if that returns early is not narrowed in 1.25.1, so keep the else. Prefer an enum when the cases have names and meaning (Shape, Command); use a union for an ad-hoc "either of these types" parameter. T | None is the same type as Option[T].

Chapter 18 shows enums with lifetime parameters, whose variants can borrow from the input they were parsed from.

Summary

  • enum E { A(x: T), B } declares variants with optional payloads; construct with E.A(x = v) or E.A(v). Enum values compare structurally.
  • match destructures variants by position, accepts literals, bindings, _ and if (…) guards, and must be exhaustive (Non-exhaustive match: missing variants …). Alternatives, ranges and @ bindings are ⚠ planned.
  • Option[T] (T?) is Some(v) or None; use ?., ??, match, unwrap_or. unwrap() on None throws UnwrapError.
  • Result[T, E] is Ok(v) or Err(e); ? propagates an Err. Printing an Err throws RenderError.
  • The methods are snake case (is_some, unwrap_or); ? on an Option in a Result function silently yields Err(None) in 1.25.1.
  • Union types A | B narrow with match type patterns (string(s) =>) or is.
  • Chapter 9 introduces classes and reference counting.