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 7: Enums and Pattern Matching

What you'll learn: how to define enums with and without payloads, take them apart with match, use guards, wildcards and nested patterns, and use the two built-in enums you will meet in almost every script, Option[T] and Result[T, E].

An enum is a type whose values are one of a fixed set of variants, each of which may carry its own data. match inspects such a value, runs the arm for its variant, and binds the variant's data to names. Together they let the compiler check that every case is handled: add a variant, and every match that forgot it stops compiling. This chapter follows the Language Reference §1-9.

Why Enums?

Without enums, a shape might be a string plus some loose numbers:

@mode(host.scripted)

let kind = "circle";
let radius = 5.0;
let width = 0.0;     # only meaningful when kind == "rect" — nothing checks that
println(kind);

Nothing stops a typo like "circel", and nothing ties radius to circles. An enum makes the relationship part of the type:

@mode(host.scripted)

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

let s = Shape.Circle(radius = 5.5);
let r = Shape.Rect(width = 3.0, height = 4.5);
println(s);
println(r);
println(Shape.Point);

Listing 7-1: An enum with payload variants and a unit variant

Circle(5.5)
Rect(3.0, 4.5)
Point

A variant with a payload is constructed like a struct, with named arguments: Shape.Circle(radius = 5.5). A variant without one, such as Point, is a value on its own. Each variant has exactly the fields it declares — a Point has no radius to get wrong.

Unit-Only Enums

When no variant carries data, an enum is a closed list of names. The idiomatic style ends each variant with ;:

@mode(host.scripted)

enum Direction {
    North;
    South;
    East;
    West;
}

func turnRight(d: Direction): Direction {
    return match d {
        North => Direction.East,
        East => Direction.South,
        South => Direction.West,
        West => Direction.North,
    };
}

let heading = Direction.North;
println(turnRight(heading));
println(turnRight(turnRight(heading)) == Direction.South);

Listing 7-2: A unit-only enum and a function that matches on it

East
true

In a match arm, a variant can be written bare (North), because the compiler knows the type being matched. Elsewhere, qualify it with the enum name (Direction.East). Payload enums separate their variants with commas, unit-only enums with semicolons; the Language Reference makes mixing the two in one enum an error (E_ENUM_MIXED_SEP), which Loup 1.25 reports only as a warning.

Enum values compare by variant and payload: Direction.North == Direction.North is true, and so is Shape.Circle(radius = 1.5) == Shape.Circle(radius = 1.5).

Taking Enums Apart with match

match value { pattern => result, … } tries each arm in order and evaluates the first whose pattern fits. A variant pattern binds the payload to names, by position:

@mode(host.scripted)

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

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

let shapes = [
    Shape.Circle(radius = 2.0),
    Shape.Rect(width = 3.0, height = 4.5),
    Shape.Triangle(base = 5.0, height = 3.0),
];
for (s in shapes) {
    println(i"{$ s $}: {$ area(s) $}");
}

Listing 7-3: Computing an area with one arm per variant

Circle(2.0): 12.56636
Rect(3.0, 4.5): 13.5
Triangle(5.0, 3.0): 7.5
  • Circle(r) matches a circle and binds its radius to r for that arm only.
  • match is an expression: here its value is the function's result. The arms are separated by commas.
  • The value being matched is written without parentheses: match shape { … }, unlike the condition of an if.

Mistake: forgetting a variant

The compiler checks that a match on an enum covers every variant (§1-9.4):

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

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

func name(shape: Shape): string {
    return match shape {
        Circle(_) => "circle",
        Rect(_, _) => "rectangle",
    };
}

println(name(Shape.Point));
main.lpscr:10:12: error[E0001]: Non-exhaustive match: missing variants Point

This is the property that makes enums worth using: when you add a variant to an enum, every match that does not handle it fails to compile, and the error names the variant. Add the arm:

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

func name(shape: Shape): string {
    return match shape {
        Circle(_) => "circle",
        Rect(_, _) => "rectangle",
        Point => "point",
    };
}

println(name(Shape.Point));
point

_ in a payload position ignores that field. As a whole pattern, _ matches anything, so a final _ => … arm makes any match exhaustive — at the price that the compiler can no longer tell you about a new variant. Prefer listing the variants.

Guards

An arm can add a condition after its pattern with if (…). The arm is chosen only when the pattern fits and the guard is true:

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

func describe(shape: Shape): string {
    return match shape {
        Circle(r) if (r > 100.0) => "huge circle",
        Circle(r) if (r > 10.0) => "medium circle",
        Circle(_) => "small circle",
        Rect(w, h) if (w == h) => i"square {$ w $}",
        Rect(w, h) => i"rectangle {$ w $} x {$ h $}",
        Point => "point",
    };
}

println(describe(Shape.Circle(radius = 150.0)));
println(describe(Shape.Circle(radius = 2.5)));
println(describe(Shape.Rect(width = 2.5, height = 2.5)));
println(describe(Shape.Rect(width = 2.5, height = 1.5)));

Listing 7-4: Guards refine an arm

huge circle
small circle
square 2.5
rectangle 2.5 x 1.5

Arms are tried top to bottom, so put the more specific guarded arms first.

Mistake: an or-pattern

Rust and F# let one arm list several patterns, A | B => …. Loup does not (§1-9.4: each arm has exactly one pattern):

This code does not compile
enum Direction {
    North;
    South;
    East;
    West;
}

func isVertical(d: Direction): bool {
    return match d {
        North | South => true,
        _ => false,
    };
}

println(isVertical(Direction.South));
main.lpscr:11:15: error[E0001]: expected FatArrow, found Pipe
main.lpscr:11:15: error[E0001]: expected expression

The parser expected => after the first pattern. Write one arm per pattern:

enum Direction {
    North;
    South;
    East;
    West;
}

func isVertical(d: Direction): bool {
    return match d {
        North => true,
        South => true,
        _ => false,
    };
}

println(isVertical(Direction.South));
println(isVertical(Direction.East));
true
false

Matching Other Values

match also works on strings, numbers and booleans, with literal patterns:

func reason(status: int): string {
    return match status {
        200 => "OK",
        404 => "Not Found",
        500 => "Internal Server Error",
        _ => "Unknown",
    };
}

println(reason(404));
println(reason(418));
Not Found
Unknown

Mistake: no catch-all arm on a literal match

The exhaustiveness check only knows the variants of enums (and the members of union types). A match on an int or a string without _ compiles, and fails when no arm fits:

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

func reason(status: int): string {
    return match status {
        200 => "OK",
        404 => "Not Found",
        500 => "Internal Server Error",
    };
}

println(reason(404));
println(reason(418));
Not Found
Uncaught MatchError: No matching arm for value in match expression
  at reason (main.lpscr:4:12)
  at <main> (main.lpscr:12:9)

Always end a match on literals with a _ arm, as in the version above.

match as a statement

When the arms do work instead of producing a value, use match as a statement and give the arms blocks. An empty block {} does nothing:

enum Level {
    Debug;
    Info;
    Warn;
}

func log(level: Level, msg: string): void {
    match level {
        Debug => {},
        Info => println(msg),
        Warn => {
            println(i"WARNING: {$ msg $}");
        },
    }
}

log(Level.Debug, "cache lookup");
log(Level.Info, "started");
log(Level.Warn, "disk 91% full");
started
WARNING: disk 91% full

A block arm used for its value yields its last expression, as an if branch does.

Nested Patterns

Patterns nest, so one arm can look through several layers:

func classify(r: Result[Option[int], string]): string {
    return match r {
        Ok(Some(n)) if (n > 0) => i"positive: {$ n $}",
        Ok(Some(n)) => i"non-positive: {$ n $}",
        Ok(None) => "no value",
        Err(msg) => i"error: {$ msg $}",
    };
}

println(classify(Ok(Some(5))));
println(classify(Ok(Some(-2))));
println(classify(Ok(None)));
println(classify(Err("timeout")));

Listing 7-5: Nested Result and Option patterns

positive: 5
non-positive: -2
no value
error: timeout

An arm is chosen only when its whole pattern matches, nested parts included (§1-9.4, ruling MATCH-2).

Generic and Recursive Enums

An enum can take type parameters and can refer to itself, which makes it a natural fit for trees:

@mode(host.scripted)

enum Tree[T] {
    Leaf(value: T),
    Node(left: Tree[T], right: Tree[T]),
}

func sum(tree: Tree[int]): int {
    return match tree {
        Leaf(v) => v,
        Node(l, r) => sum(l) + sum(r),
    };
}

let tree: Tree[int] = Tree.Node(
    left = Tree.Leaf(value = 1),
    right = Tree.Node(
        left = Tree.Leaf(value = 2),
        right = Tree.Leaf(value = 3),
    ),
);
println(tree);
println(sum(tree));

Listing 7-6: A generic binary tree

Node(Leaf(1), Node(Leaf(2), Leaf(3)))
6

Option[T]: a Value That May Be Absent

Loup has no null. A value that may be missing has the type Option[T] (also written T?), whose variants are Some(value) and None. Both are available everywhere without an import:

@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;
}

let name = match findUser(2) {
    Some(u) => u.name,
    None => "Unknown",
};
println(name);

println(findUser(9)?.name ?? "Guest");

let fallback = User(id = 0, name = "nobody");
println(findUser(7).unwrap_or(fallback).name);
println(findUser(1).is_some());

Listing 7-7: Producing and consuming an Option

Grace
Guest
nobody
true
  • match with Some(u) and None arms handles both cases explicitly.
  • opt?.field reads a field when the option is Some, and gives None otherwise; opt ?? default unwraps an option or supplies a default.
  • unwrap_or(default), is_some() and is_none() are methods on Option. unwrap() returns the value or throws UnwrapError on None; use it only when absence is a bug.

Mistake: the spec's camelCase method names

The Language Reference writes the Option and Result helpers in camelCase (unwrapOr, isSome). Loup 1.25 implements them in snake_case:

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

let ports = {"http": 80, "https": 443};
let ssh = ports.get("ssh");
if (ssh.isSome()) {
    println("ssh is configured");
}
println(ssh.unwrapOr(22));
main.lpscr:5:5: error[E0001]: type 'Option[int]' has no method 'isSome'
main.lpscr:8:9: error[E0001]: type 'Option[int]' has no method 'unwrapOr'

Use the snake_case names — or, more often, a match or ??:

@mode(host.scripted)

let ports = {"http": 80, "https": 443};
let ssh = ports.get("ssh");
if (ssh.is_some()) {
    println("ssh is configured");
}
println(ssh.unwrap_or(22));
println(ports.get("https") ?? 443);
22
443

Be careful with Result: there, r.unwrapOr(0) compiles in Loup 1.25 and then fails at run time with Uncaught RuntimeError: internal interpreter limitation: … unwrapOr … method not found. Write unwrap_or everywhere.

Result[T, E]: Success or Failure

Result[T, E] has the variants Ok(value) and Err(error). A function returns it when failure is an expected outcome the caller should handle:

func parsePort(text: string): Result[int, string] {
    if (text.length() == 0) {
        return Err("empty port");
    }
    for (ch in text.chars()) {
        if (not "0123456789".contains(ch)) {
            return Err(i"'{$ text $}' is not a number");
        }
    }
    return Ok(text.toInt());
}

for (input in ["8080", "80a", ""]) {
    let message = match parsePort(input) {
        Ok(port) => i"port {$ port $}",
        Err(problem) => i"error: {$ problem $}",
    };
    println(message);
}
port 8080
error: '80a' is not a number
error: empty port

(text.toInt() alone returns 0 for text that is not a number, which is why the function checks the characters first.) Inside a function that itself returns a Result, the ? operator unwraps an Ok or returns the Err to the caller at once; the evaluator at the end of this chapter uses it, and Chapter 10 covers it with the rest of error handling.

Union Types

A union type string | int | bool holds a value of any of its member types. match takes it apart with type patterns, and checks that every member is covered:

func describe(value: string | int | bool): string {
    return match value {
        string(s) => i"text: '{$ s $}'",
        int(n) => i"number: {$ n $}",
        bool(b) => i"flag: {$ b $}",
    };
}

println(describe("hello"));
println(describe(42));
println(describe(true));
text: 'hello'
number: 42
flag: true

For a single test, is narrows the type inside an if (§1-9.5):

func normalize(value: string | int): string {
    if (value is string) {
        return value.toUpperCase();    # value is a string here
    } else {
        return str(value + 1);         # and an int here
    }
}

println(normalize("abc"));
println(normalize(41));
ABC
42

Use an enum when the cases have names and meanings of their own; use a union for an ad-hoc "one of these types".

A Worked Example: A Command Parser

Enums are a good model for the commands of a small interactive tool. Parsing produces a Result[Command, string], and running a command is a match:

@mode(host.scripted)

from core.string_utils import (isDigits);

enum Direction {
    North;
    South;
    East;
    West;
}

enum Command {
    Quit,
    Echo(message: string),
    Move(direction: Direction, steps: int),
    Help(topic: Option[string]),
}

func parseDirection(word: string): Option[Direction] {
    return match word {
        "north" => Some(Direction.North),
        "south" => Some(Direction.South),
        "east" => Some(Direction.East),
        "west" => Some(Direction.West),
        _ => None,
    };
}

func parseCommand(input: string): Result[Command, string] {
    let words = input.trim().split(" ");
    let verb = words[0];
    return match verb {
        "quit" => Ok(Command.Quit),
        "echo" => Ok(Command.Echo(message = words[1:].join(" "))),
        "move" if (words.length() == 3) => {
            match parseDirection(words[1]) {
                None => Err(i"unknown direction '{$ words[1] $}'"),
                Some(d) if (isDigits(words[2])) => Ok(Command.Move(direction = d, steps = words[2].toInt())),
                Some(_) => Err(i"'{$ words[2] $}' is not a number of steps"),
            }
        },
        "move" => Err("usage: move <direction> <steps>"),
        "help" => Ok(Command.Help(topic = if (words.length() > 1) { Some(words[1]) } else { None })),
        _ => Err(i"unknown command '{$ verb $}'"),
    };
}

func run(cmd: Command): string {
    return match cmd {
        Quit => "Goodbye!",
        Echo(msg) => msg,
        Move(dir, steps) => i"moving {$ dir $} {$ steps $} steps",
        Help(Some(topic)) => i"help on {$ topic $}",
        Help(None) => "commands: quit, echo, move, help",
    };
}

for (line in ["echo hello there", "move north 3", "move up 3", "move west x", "help", "help move", "dance", "quit"]) {
    let out = match parseCommand(line) {
        Ok(cmd) => run(cmd),
        Err(problem) => i"error: {$ problem $}",
    };
    println(i"> {$ line $}");
    println(out);
}

Listing 7-8: Parsing and running commands

> echo hello there
hello there
> move north 3
moving North 3 steps
> move up 3
error: unknown direction 'up'
> move west x
error: 'x' is not a number of steps
> help
commands: quit, echo, move, help
> help move
help on move
> dance
error: unknown command 'dance'
> quit
Goodbye!

Notice how Help(Some(topic)) and Help(None) peel two layers in one pattern, and how the guard on the first "move" arm sends a command with the wrong number of words to the usage message. isDigits comes from the standard library module core.string_utils.

A Worked Example: An Expression Evaluator

A recursive enum represents a tree of arithmetic, and two recursive match functions evaluate and print it. Evaluation returns a Result, so division by zero is an ordinary value rather than a crash:

@mode(host.scripted)

enum Expr {
    Num(value: float),
    Add(left: Expr, right: Expr),
    Mul(left: Expr, right: Expr),
    Div(left: Expr, right: Expr),
    Neg(inner: Expr),
}

func divide(a: float, b: float): Result[float, string] {
    if (b == 0.0) {
        return Err("division by zero");
    }
    return Ok(a / b);
}

func eval(expr: Expr): Result[float, string] {
    return match expr {
        Num(v) => Ok(v),
        Add(l, r) => Ok(eval(l)? + eval(r)?),
        Mul(l, r) => Ok(eval(l)? * eval(r)?),
        Div(l, r) => divide(eval(l)?, eval(r)?),
        Neg(inner) => Ok(-eval(inner)?),
    };
}

func show(expr: Expr): string {
    return match expr {
        Num(v) => str(v),
        Add(l, r) => i"({$ show(l) $} + {$ show(r) $})",
        Mul(l, r) => i"({$ show(l) $} * {$ show(r) $})",
        Div(l, r) => i"({$ show(l) $} / {$ show(r) $})",
        Neg(inner) => i"-{$ show(inner) $}",
    };
}

let three = Expr.Num(value = 3.0);
let two = Expr.Num(value = 2.0);

# 3 + 2 * -4.5
let good = Expr.Add(left = three, right = Expr.Mul(left = two, right = Expr.Neg(inner = Expr.Num(value = 4.5))));
# 3 / (2 + -2)
let bad = Expr.Div(left = three, right = Expr.Add(left = two, right = Expr.Neg(inner = two)));

for (e in [good, bad]) {
    let text = match eval(e) {
        Ok(result) => i"{$ show(e) $} = {$ result $}",
        Err(msg) => i"{$ show(e) $}: {$ msg $}",
    };
    println(text);
}

Listing 7-9: Evaluating an expression tree with Result and ?

(3 + (2 * -4.5)) = -6
(3 / (2 + -2)): division by zero

eval(l)? evaluates the left side and, if that produced an Err, returns it from eval immediately. (Whole-number floats print without .0 — 3, -6 — when printed on their own.)

Summary

  • enum Name { Variant(field: Type), Other, } declares a closed set of variants; payload variants are constructed with named arguments (Shape.Circle(radius = 2.0)).
  • match value { Pattern => result, … } is an expression; variant patterns bind payloads by position, _ ignores, and if (…) guards refine an arm.
  • A match on an enum must cover every variant; a match on literals needs a _ arm or it throws MatchError at run time.
  • One pattern per arm: there are no A | B or-patterns.
  • Option[T] (Some/None) replaces null; use match, ?., ??, unwrap_or, is_some. The 1.25 method names are snake_case.
  • Result[T, E] (Ok/Err) reports expected failures; ? propagates them (Chapter 10).
  • Union types are matched with type patterns (string(s)) or narrowed with is.

Try It Yourself

  1. Define enum Json { Null, Bool(b: bool), Num(n: float), Str(s: string), Arr(items: list[Json]) } and write render(j: Json): string with match.
  2. Add a Teleport(x: int, y: int) variant to Command in Listing 7-8 and run the script before handling it. Read the error, then add the arm.
  3. Write func firstWord(line: string): Option[string] that returns None for a blank line, and print firstWord(line) ?? "(blank)" for a few lines.
  4. Extend the evaluator with Sub(left: Expr, right: Expr) and make show print it.