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 torfor that arm only.matchis 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 anif.
Mistake: forgetting a variant
The compiler checks that a match on an enum covers every variant (§1-9.4):
@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):
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:
@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
matchwithSome(u)andNonearms handles both cases explicitly.opt?.fieldreads a field when the option isSome, and givesNoneotherwise;opt ?? defaultunwraps an option or supplies a default.unwrap_or(default),is_some()andis_none()are methods onOption.unwrap()returns the value or throwsUnwrapErroronNone; 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:
@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, andif (…)guards refine an arm.- A
matchon an enum must cover every variant; amatchon literals needs a_arm or it throwsMatchErrorat run time. - One pattern per arm: there are no
A | Bor-patterns. Option[T](Some/None) replaces null; usematch,?.,??,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 withis.
Try It Yourself
- Define
enum Json { Null, Bool(b: bool), Num(n: float), Str(s: string), Arr(items: list[Json]) }and writerender(j: Json): stringwithmatch. - Add a
Teleport(x: int, y: int)variant toCommandin Listing 7-8 and run the script before handling it. Read the error, then add the arm. - Write
func firstWord(line: string): Option[string]that returnsNonefor a blank line, and printfirstWord(line) ?? "(blank)"for a few lines. - Extend the evaluator with
Sub(left: Expr, right: Expr)and makeshowprint it.