Chapter 6: Enums and Pattern Matching
Enums carry a lot of the weight in freestanding code. Device states are enums, error types are enums, and so are the legal values of most register fields. Because match must be exhaustive, the code that handles an enum cannot silently drop a case — which matters when there is no exception to catch the case you forgot.
This chapter covers plain and data-carrying enums, Option[T] and Result[T, E], the patterns match understands, union types, and why errors in freestanding are values rather than exceptions.
Plain enums
A plain enum is a set of named variants, separated by commas. Variants are written with the enum's name, LinkState.Up, both when you construct one and in a match pattern:
@mode(freestanding)
enum LinkState { Down, Training, Up }
func ledPattern(s: LinkState): uint8 {
return match s {
LinkState.Down => 0x00u8,
LinkState.Training => 0x55u8, # blink
LinkState.Up => 0xFFu8,
}
}
func isUsable(s: LinkState): bool {
return s == LinkState.Up # enum equality is structural
}
Listing 6-1: A plain enum and an exhaustive match
The variants need the commas. Writing them one per line without commas, as in a C or Swift enum, fails at the second variant:
@mode(freestanding)
enum LinkState {
Down
Training
Up
}
error[E0002]: expected `}`
--> src/main.lpfree:5:5
|
5 | Training
| ^^^^^^^^
|
error[E0001]: expected expression
--> src/main.lpfree:7:1
|
7 | }
| ^
|
One variant per line is fine — with a comma after each (a trailing comma after the last one is allowed). An enum must have at least one variant; an empty enum E {} is E_ENUM_NO_VARIANTS, and the uninhabited type is spelled impossible.
A fieldless enum is stored as its tag, one pointer-sized word (Language Reference §3-3.4.1). It does not have a guaranteed numeric encoding, so when a hardware register field has defined values, keep the register as an integer and convert explicitly:
enum Parity { None, Odd, Even }
# 16550 line control register: bits 3..5 select parity.
func parityBits(p: Parity): uint8 {
return match p {
Parity.None => 0x00u8,
Parity.Odd => 0x08u8,
Parity.Even => 0x18u8,
}
}
func parityFromBits(lcr: uint8): Option[Parity] {
return match lcr & 0x38u8 {
0x00u8 => Some(Parity.None),
0x08u8 => Some(Parity.Odd),
0x18u8 => Some(Parity.Even),
_ => None, # a reserved encoding
}
}
Listing 6-2: Converting between an enum and a register encoding
Data-carrying enums
A variant can carry named fields. Construct it with named arguments; in a pattern, bind the fields by position:
enum DiskError {
Timeout,
BadSector(lba: uint64),
Mismatch(expected: uint32, actual: uint32),
}
func errorCode(e: DiskError): uint32 {
return match e {
DiskError.Timeout => 1u32,
DiskError.BadSector(lba) => 2u32,
DiskError.Mismatch(expected, actual) => 3u32,
}
}
func checkCrc(expected: uint32, actual: uint32): Option[DiskError] {
if (expected != actual) {
return Some(DiskError.Mismatch(expected = expected, actual = actual))
}
return None
}
Listing 6-3: Variants with payloads
Exhaustiveness
Every variant must be handled, either by its own arm or by a _ wildcard arm. Add a variant to an enum and the compiler finds every match that has to change:
@mode(freestanding)
enum LinkState { Down, Training, Up, Error(code: uint8) }
func ledPattern(s: LinkState): uint8 {
return match s {
LinkState.Down => 0x00u8,
LinkState.Training => 0x55u8,
LinkState.Up => 0xFFu8,
}
}
error[E0001]: Non-exhaustive match: missing variants Error
--> src/main.lpfree:6:12
|
6 | return match s {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
Handle the new case explicitly rather than reaching for _ — the next variant someone adds should also be flagged:
enum LinkState { Down, Training, Up, Error(code: uint8) }
func ledPattern(s: LinkState): uint8 {
return match s {
LinkState.Down => 0x00u8,
LinkState.Training => 0x55u8,
LinkState.Up => 0xFFu8,
LinkState.Error(code) => 0xF0u8 | (code & 0x0Fu8),
}
}
Listing 6-4: Handling the new variant
Patterns and guards
match arms can use:
- variant patterns, with bindings:
DiskError.BadSector(lba); - literal patterns:
0u32,true,"on"; - a binding pattern that names the whole value:
n; - the wildcard
_; - a guard after any pattern:
n if (n < 100u32) => …(the guard condition is parenthesized like every other condition).
Arms are tried in order, so a guarded arm must come before a more general one.
# Bucket an interrupt latency in microseconds for a histogram.
func latencyBucket(us: uint32): uint8 {
return match us {
0u32 => 0u8,
n if (n < 10u32) => 1u8,
n if (n < 100u32) => 2u8,
n if (n < 1000u32) => 3u8,
_ => 4u8,
}
}
Listing 6-5: Literal patterns, bindings and guards
Option[T] and Result[T, E]
Two generic enums are built in: Option[T] with the variants Some(value) and None, and Result[T, E] with Ok(value) and Err(error). The constructors are available everywhere without an import.
Use Option for "might be absent" — a table lookup, a free slot, a reserved encoding as in Listing 6-2. Use Result for "might have failed", with an enum as the error type:
enum RegError { BadOffset(offset: uint32), Busy }
var regs: buffer[uint32, 64];
func readRegister(offset: uint32): Result[uint32, RegError] {
if (offset >= 64u32) {
return Err(RegError.BadOffset(offset = offset))
}
return Ok(regs[offset as int])
}
func readAndIncrement(offset: uint32): Result[uint32, RegError] {
let current = readRegister(offset)? # an Err returns from this function
return Ok(current + 1u32)
}
func firstFreeSlot(used: uint64): Option[int] {
var i = 0
while (i < 64) {
if ((used & (1u64 << (i as uint64))) == 0u64) {
return Some(i)
}
i = i + 1
}
return None
}
func slotOrDefault(used: uint64): int {
let slot = firstFreeSlot(used)
if (slot.is_some()) {
return slot.unwrap()
}
return firstFreeSlot(0u64).unwrap_or(0)
}
Listing 6-6: Result with an error enum, ?, and Option helpers
The helper methods use snake_case names: is_some(), is_none(), is_ok(), is_err(), unwrap(), unwrap_or(default), unwrap_err(). (The Language Reference spells some of them in camelCase, such as unwrapOr; use the snake_case names the toolchain implements.) x ?? default is shorthand for unwrapping an Option with a fallback.
unwrap() on None is a panic. In freestanding a panic is a stop handled by the package's panic policy — halt the core by default, or call your @panic_handler under panic = "custom" (chapter 17). Use unwrap() only where None really is impossible, and prefer match, ?? or unwrap_or everywhere else.
Chapter 9 covers Result, ? and error conversion in depth.
Union types
A union type lists alternatives inline: uint32 | bool holds either a uint32 or a bool. T | None is the same type as Option[T]. You narrow a union with type patterns in a match, or with is in an if:
# A boot option is either a number or an on/off switch.
func parseOption(s: string): uint32 | bool {
if (s == "on") { return true }
if (s == "off") { return false }
return 0u32
}
func optionWeight(v: uint32 | bool): uint32 {
return match v {
uint32(n) => n,
bool(b) => if (b) { 1u32 } else { 0u32 },
}
}
func isNumeric(v: uint32 | bool): bool {
if (v is uint32) {
return true
}
return false
}
Listing 6-7: A union type narrowed by type patterns and is
Type patterns are exhaustive too: leave out the bool(b) arm and the compiler reports Non-exhaustive match: missing types bool. In freestanding every member of a union must be a concrete type known at compile time; Any is not allowed, because naming its type at run time would need reflection metadata. Prefer an enum when the alternatives mean something — a named variant documents itself; a union is for genuinely ad-hoc "this or that type" values.
Errors are values in freestanding
In the hosted modes a function can throw and a caller further up can catch. A freestanding image has no unwinder and no .eh_frame, so exceptions do not exist there. A habit from hosted code is caught at once:
@mode(freestanding)
enum UartError { Timeout, Framing, Overrun }
func readByte(ready: bool, data: uint8): uint8 {
if (not ready) {
throw UartError.Timeout
}
return data
}
error[E_FREESTANDING_EXCEPTIONS]: E_FREESTANDING_EXCEPTIONS: `throw` is not available in `@mode(freestanding)` — the mode has no unwinder and no `.eh_frame`. Use `Result[T, E]` with `?` or `attempt` (§1-5.10).
--> src/main.lpfree:7:9
|
7 | throw UartError.Timeout
| ^^^^^^^^^^^^^^^^^^^^^^^
|
error[E_THROW_NON_ERROR]: cannot throw value of type 'UartError': thrown values must extend Error
--> src/main.lpfree:7:9
|
7 | throw UartError.Timeout
| ^^^^^^^^^^^^^^^^^^^^^^^
|
The first error is the freestanding rule. The second would apply in any mode: only exception classes can be thrown, not enum values. The freestanding version returns the error as a value:
enum UartError { Timeout, Framing, Overrun }
func readByte(ready: bool, data: uint8): Result[uint8, UartError] {
if (not ready) {
return Err(UartError.Timeout)
}
return Ok(data)
}
func readPair(ready: bool, a: uint8, b: uint8): Result[uint16, UartError] {
let high = readByte(ready, a)?
let low = readByte(ready, b)?
return Ok(((high as uint16) << 8) | (low as uint16))
}
Listing 6-8: Returning an error enum instead of throwing
Every enum you define for a subsystem is likely to end up as somebody's error type, and its variants are the list of things that can go wrong.
Summary
- Enum variants are comma-separated and written
Enum.Variant; variants can carry named fields. matchis exhaustive — missing variants (or union member types) are compile errors; guards arepattern if (condition).- A fieldless enum has no guaranteed encoding; map register values explicitly.
Option[T]andResult[T, E]are built in; their helpers are snake_case (is_some,unwrap_or);unwrap()onNoneis a panic under the package's panic policy.- Union types such as
uint32 | boolare narrowed with type patterns oris;Anyis not allowed in freestanding unions. throwisE_FREESTANDING_EXCEPTIONS; returnErr(...)and propagate with?.
Next: classes and allocators.