Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Enums and Option

An enum is a closed set of named variants, each optionally carrying a payload. A value is one of them and knows which. You take one apart with match, and the checker knows when you have missed a case.

enum Tile {
    Empty
    Wall
    Number(Int)
    Portal(Text)
}

// A variant is constructed by naming it. One with a payload is called.
out(Empty)
out(Number(7))
out(Portal("ab"))

var tiles = [Empty, Wall, Number(3), Portal("z")]
for t in tiles {
    out(match t {
        Empty => 1
        Wall => 0
        Number(n) => n
        Portal(_) => 100
    })
}
Empty
Number(7)
Portal(ab)
1
0
3
100

Variants are separated by a comma or a line break, so enum Tile { Empty, Wall } and the multi-line form are the same declaration. A constructor is used bare, not qualified: Empty, not Tile::Empty — there is no path syntax.

Payloads

A payload is a parenthesized list of types. There may be more than one, and the pattern names them by position.

enum Move {
    Step(Int, Int)
    Stay
}

// A payload may hold more than one value, and the pattern names them by
// position.
var m = Step(1, 2)
out(m)
out(match m { Step(dx, dy) => dx * 10 + dy, Stay => 0 })

// A wildcard stands in for a payload slot the arm does not need, and `Step(_,
// _)` is how you say "any payload". A bare `Step` is not: a variant that
// carries a payload has to say so in the pattern.
out(match m { Step(_, _) => 1, Stay => 0 })

var at = (0, 0)
for step in [Step(1, 0), Step(0, 2), Stay, Step(3, 4)] {
    at = match step {
        Step(dx, dy) => match at { (x, y) => (x + dx, y + dy) }
        Stay => at
    }
}
out(at)
Step(1, 2)
12
1
(4, 6)

Inside the parentheses you may name fewer sub-patterns than the payload has: the rest are wildcards, so Step(dx) binds the first slot and ignores the second.

Two things are Y124. Naming more than the payload has:

enum Wrapped { Wrap(Int) }

// Naming *more* sub-patterns than the payload has is `Y124`, from analysis —
// so `praxis check`, `praxis run` and the editor all report it.
fn value(w: Wrapped) -> Int {
    match w { Wrap(a, b) => a + b }
}

out(value(Wrap(1)))
$ praxis check docs/book/examples/records-enums/match-too-many-sub-patterns.px
error[Y124]: `Wrap` in `Wrapped` holds 1 value(s), but this pattern names 2

  match-too-many-sub-patterns.px:6:15
  6 |     match w { Wrap(a, b) => a + b }
    |               ^^^^ `Wrap` in `Wrapped` holds 1 value(s), but this pattern names 2

praxis: 1 error(s)

…and naming a payload-carrying variant with no parentheses at all. Stay is a pattern because Stay carries nothing; a bare Step is not:

error[Y124]: `Step` in `Move` holds 2 value(s), but this pattern names 0

help: name the payload, or `_` for each slot you do not need
      Step(_, _)

The parentheses are where you said what you were doing. A bare name says nothing about the value the variant holds, and reads exactly like the payload-less Stay beside it — which is the whole reason it is refused.

An enum declaration is not generic — a variant’s payload types are concrete — and a declaration that reaches itself through a payload type is the same N006 a self-referring record gets.

An enum value records its type

Equality is same type, same variant, equal payloads, and hashing agrees with it, so an enum value is a map key or a set element like any other.

enum Tile { Empty, Wall, Number(Int) }

// Equality is same variant and equal payloads; hashing follows it, so an enum
// value is a map key or a set element like any other.
out(Number(7) == Number(7))
out(Number(7) == Number(8))
out(Empty == Wall)

var seen = Set()
seen.insert(Number(1))
seen.insert(Number(1))
seen.insert(Empty)
out(seen.len())
true
false
false
2

“Same type” is real, not a tag comparison: a value carries a schema naming the enum it belongs to and the shape of each variant, so two enums whose variants line up are still two types. That is what makes Some(3) print as Some(3) rather than as a bare tag, what keeps an Option[Int] the runtime built from an Option[Int] the compiler built, and what lets the debugger say what it is looking at.

Statically the type checker usually gets there first. Two enums may declare the same variant name; in expression position the name resolves like any other name, so a later declaration shadows an earlier one:

enum Colour { Red, Green }
enum Light { Red, Amber }

// An enum value records which enum type it is, so a `Colour` and a `Light` are
// never the same value — and here they are not even the same type. In
// *expression* position `Red` is an ordinary name, and the later declaration
// shadows the earlier one, so this `Red` is `Light`'s.
var c: Colour = Red
out(c)
$ praxis check docs/book/examples/records-enums/enum-value-knows-its-type.px
error[Y001]: expected Colour, found Light

  enum-value-knows-its-type.px:8:17
  8 | var c: Colour = Red
    |                 ^^^ expected Colour, found Light

praxis: 1 error(s)

In pattern position there is no such problem: a variant pattern’s enum is the scrutinee’s, so match c { Red => … } and match l { Red => … } each read their own. That is in the pattern chapter.

Enums are not ordered, for the same reason records are not — nothing says which variant or which payload decides:

enum Tile { Empty, Number(Int) }

// Like records, enums are equatable and hashable but not ordered.
out([Number(1), Empty].sorted())
$ praxis check docs/book/examples/records-enums/enum-cannot-be-ordered.px
error[Y006]: values of type `Tile` cannot be ordered

  enum-cannot-be-ordered.px:4:24
  4 | out([Number(1), Empty].sorted())
    |                        ^^^^^^ values of type `Tile` cannot be ordered

praxis: 1 error(s)

Option

Option[T] is the one generic definition in the language and it is an enum: Some(T) and None. It is what the standard library answers when a value may legitimately be absent. It is not an error channel — a program that runs out of budget or indexes past the end faults; a lookup that finds nothing answers None.

// `Option[T]` is an ordinary enum with two variants, `Some(T)` and `None`. It
// is what a library answers when a value may legitimately be absent.
var counts = Map()
counts["a"] = 1

out(counts.get("a"))
out(counts.get("z"))
out(match counts.get("z") { Some(n) => n, None => 0 })

var words = ["alpha", "beta"]
out(words.find(|w| w == "beta"))
out(words.find(|w| w == "gamma"))
out(words.position(|w| w == "beta"))

out(9000000000000000000.checked_add(9000000000000000000))
out(2.checked_add(3))

// A closure may build one: `filter_map` keeps the `Some`s and drops the `None`s.
out([1, 2, 3].filter_map(|n| if n % 2 == 1 { Some(n * 10) } else { None }))

// And a function may declare one.
fn first_big(v: Vec[Int]) -> Option[Int] {
    v.find(|n| n > 1)
}
out(first_big([1, 2, 3]))
Some(1)
None
0
Some(beta)
None
Some(1)
None
Some(5)
[10, 30]
Some(2)

Some and None are ordinary constructors: you build them, annotate with Option[T], store them in collections, and match them. There are no methods on an Option — no unwrap, no is_some, no ? operator. A match is how you get the value out, and it is two tokens more than an unwrap would be.

What answers an Option

SignatureAbsent means
Map[K, V].get(K) -> Option[V]the key is not in the map
Vec[T].find((T) -> Bool) -> Option[T]nothing matched — the element, not its index
Vec[T].position((T) -> Bool) -> Option[Int]nothing matched — the index
Grid[T].find(T) -> Option[(Int, Int)]the value is nowhere in the grid
Int.checked_add/sub/mul(Int) -> Option[Int]the result overflowed

filter_map’s closure returns Option[U], which is how it drops elements.

Three near neighbours deliberately answer something else. Counter[T].get answers a plain count, because a counter’s absent value is zero rather than absent. v.min() and v.max() on an empty sequence fault: an empty minimum is a mistake in the program, not domain-level absence, and making it an Option would force an unwrap at every call site for a case the caller has already ruled out. Grid.find_all answers a Vec, which already encodes “nothing matched” as emptiness.

An Option is not the value

.get answers an Option[V], so it does not do arithmetic, index or compare as a V. This is the most common first surprise:

var counts = Map()
counts["a"] = 1

// `.get` answers an `Option`, so it is not an `Int` until a `match` takes it
// apart. Where the key is known to be present, index instead: `counts["a"]`.
out(counts.get("a") + 1)
$ praxis check docs/book/examples/records-enums/option-is-not-the-value.px
error[Y001]: expected Int, found Option[Int]

  option-is-not-the-value.px:6:5
  6 | out(counts.get("a") + 1)
    |     ^^^^^^^^^^^^^^^ expected Int, found Option[Int]

praxis: 1 error(s)

There are two spellings and you pick between them: counts.get(k) is explicit absence, and counts[k] is assertion-like access that faults on a miss. Where the key was just inserted three lines up, index. Where it might not be there, match.

Anonymous enums

The input parser’s choice constructor derives an enum with no declaration, one variant per case, each carrying the case’s own payload. It renders as its variants — { Mul({ a: Int, b: Int }) | Do(Unit) | Dont(Unit) } — and it behaves like a declared enum in every way except that it has no name to write in an annotation. Matching one is in the pattern chapter.