11 Types that hold answers — option and result
What to know first
guard and panicmatchLooking back
What does case rect w h . in chapter 10 do? And what happens when a match leaves out a variant?
A. When the variant is rect, it binds the two values the variant carries to w and h. Leaving out a variant is rejected with E-MATCH-INEXHAUSTIVE. The option and result of this chapter behave like two-variant enums the language made in advance — a value is there or not, it succeeded or failed.
The need for this chapter, and its context
option and result move that question into the type. This chapter sits in the middle of Part III because nearly every op that takes data out — a lookup, a conversion, a parser — returns “may be missing” or “may fail”.By the end of this chapter
option with some and none, and to use it by asking and taking out, by giving a fallback with value_or, or by splitting with match. You will see that taking out without checking stops. You will also see that result pairs with an errors clause, that try passes a failure upwards, and that the else_none and else_error tails cross between the two. Finally you will settle the criterion that separates the three ways of reporting failure — result, option and panic — and see or-patterns, nested patterns and match folded at translation time.The questions this chapter answers
- If I put an expensive computation in
value_or’s default, is it computed every time?
11.1 option — a value, or none#
option t is either a value of t (some v) or nothing (none). Together with result, seen later, it helps to draw them as two kinds of box that hold a value.
option u64 ┌──────────┐ result u64 e ┌──────────────┐
│ some 20 │ a value │ ok 20 │ a value
├──────────┤ ├──────────────┤
│ none │ empty │ error bad │ it failed ---
└──────────┘ (nothing was there) └──────────────┘ and carries why (bad)none is not something strange but the normal answer “there was nothing”, and error is the answer “what I tried failed”. Either way, the value inside cannot be used before the box is opened.
examples/ch11/lookup.low
module lookup .
rem run: find 2
rem run: find 7
rem run: find_or 7
rem run: find_asked 2
rem run: find_match 7
rem trap: find_raw 7
fn find input k u8 . output option u8 .
do
guard lt k 3 . else return none .
return some (mul k 10) .
end
fn find_or input k u8 . output u8 .
do
return value_or (find k) 99 .
end
fn find_asked input k u8 . output u8 .
do
let r option u8 be find k .
guard is_some r . else return 255 .
return some_value r .
end
fn find_match input k u8 . output u8 .
do
match find k do
case some v . do return v . end
case none . do return 0 . end
end
end
fn find_raw input k u8 . output u8 .
do
return some_value (find k) .
end
Output
$ lowentc --run find lookup.low 2
find(2) = some 20
$ lowentc --run find lookup.low 7
find(7) = none
$ lowentc --run find_or lookup.low 7
find_or(7) = 99
$ lowentc --run find_asked lookup.low 2
find_asked(2) = 20
$ lowentc --run find_match lookup.low 7
find_match(7) = 0
$ lowentc --run find_raw lookup.low 7
== ir diagnostics (1) ==
0:0 E-VM-NONE: some_value of none (panic)
find, the producer, wraps values with return none . and return some (mul k 10) ., and the VM shows the results as some 20 and none. The receiver can use it three ways.
find_or—value_or (find k) 99gives the value if there is one, and 99 otherwise.find_asked— asks first withguard is_some r . else …and takes the value out withsome_value r.find_match— splits withmatchintocase some v .andcase none .. The two arms cover every case.
find_raw takes the value out without asking. Translation passes, but execution stops at 7, which has no value (E-VM-NONE). Taking a value out is a partial operation. On which paths a value exists is something the author knows and the processor cannot always know, so translation does not block it. Instead it is not silent when wrong — it does not hand out 0 and carry on.
Q. If I put an expensive computation in value_or’s default, is it computed every time?
A. No. The default is computed only when there is no value. value_or (some 7) (div 1 0) is 7, and no division by zero happens. So you may put a computation that can fail in the default. This behaviour was once the other way round and was fixed to match the specification.
11.2 result and the errors clause#
result t e is either a successful value (ok v) or an error (error <variant>). The error type e is usually an enum. And an op that returns a result writes when it produces which error in its errors clause.
examples/ch11/halve.low
module halving .
rem run: halve 100
rem run: halve 250
rem run: halve 101
rem run: halve_plus_one 100
rem run: halve_plus_one 250
rem run: halve_or_zero 250
enum io_error do
too_big .
odd .
end
fn halve input a u8 . output result u8 io_error .
errors too_big gt a 200 .
errors odd ne (mod a 2) 0 .
do
guard le a 200 . else return error too_big .
guard eq (mod a 2) 0 . else return error odd .
return ok (div a 2) .
end
fn halve_plus_one input a u8 . output result u8 io_error .
errors too_big gt a 200 .
errors odd ne (mod a 2) 0 .
do
let v u8 be try halve a .
return ok (add v 1) .
end
fn halve_or_zero input a u8 . output u8 .
do
let r result u8 io_error be halve a .
guard not (is_error r) . else return 0 .
return ok_value r .
end
Output
$ lowentc --run halve halve.low 100
halve(100) = ok 50
$ lowentc --run halve halve.low 250
halve(250) = err too_big
$ lowentc --run halve halve.low 101
halve(101) = err odd
$ lowentc --run halve_plus_one halve.low 100
halve_plus_one(100) = ok 51
$ lowentc --run halve_plus_one halve.low 250
halve_plus_one(250) = err too_big
$ lowentc --run halve_or_zero halve.low 250
halve_or_zero(250) = 0
halve’s head lists two errors. errors too_big gt a 200 . is the promise “if a is greater than 200, produce too_big”. This clause is a contract on the way out. Returning normally while the condition holds, or returning an error not written, is a contract violation. Code that returns an error not written is rejected at translation.
examples/ch11/undeclared.low
module undeclared .
rem expect: E-ERR-UNDECLARED
enum parse_error do
empty .
too_long .
end
fn first_byte input s slice u8 . output result u8 parse_error .
errors empty eq (len s) 0 .
do
guard gt (len s) 0 . else return error empty .
guard le (len s) 16 . else return error too_long .
return ok (index s 0) .
end
Output
$ lowentc --check undeclared.low
undeclared.low:13:0 E-ERR-UNDECLARED: op returns an error not in its errors clause
too_long is a variant of parse_error, but it is not in first_byte’s errors clause. A caller reading the head would believe handling empty is enough. When what is written and what is produced differ, one side meets failures it never saw and the other handles failures that never come.
11.3 try — passing failure upwards#
Writing the failure check by hand every time makes code long, and long code skips checks. try takes a result and, on success, takes out the value; on failure it returns that error as is and leaves the op. halve_plus_one in halve.low has that shape.
let v u8 be try halve a .
return ok (add v 1) .Notice that halve_plus_one writes errors in its own head too. To pass an error up with try, it must itself be able to return that error, and it must say so in its contract. There is no path by which a failure silently disappears. To handle the failure here instead of passing it up, ask with is_error and take out with ok_value, as halve_or_zero does.
A common misconception. try is the try of exception handling
try is a place that catches exceptions thrown anywhere in a block. Lowent’s try is attached to one expression and marks that expression’s failure as passed upwards; it is closer to Rust’s ?. There is no throw-and-catch control flow. Failures always come back as values, and where they can be passed on is written in the source.11.4 Crossing between the two channels#
Sometimes the calling op and the called op use different channels. A tail on try changes the container.
examples/ch11/tails.low
module tails .
rem run: maybe_half 100
rem run: maybe_half 250
rem run: must_find 2
rem run: must_find 9
enum lookup_error do
too_big .
not_found .
end
fn halve input a u8 . output result u8 lookup_error .
errors too_big gt a 200 .
do
guard le a 200 . else return error too_big .
return ok (div a 2) .
end
fn find input k u8 . output option u8 .
do
guard lt k 3 . else return none .
return some (mul k 10) .
end
fn maybe_half input a u8 . output option u8 .
do
return try (halve a) else_none .
end
fn must_find input k u8 . output result u8 lookup_error .
errors not_found .
do
return try (find k) else_error not_found .
end
Output
$ lowentc --run maybe_half tails.low 100
maybe_half(100) = some 50
$ lowentc --run maybe_half tails.low 250
maybe_half(250) = none
$ lowentc --run must_find tails.low 2
must_find(2) = ok 20
$ lowentc --run must_find tails.low 9
must_find(9) = err not_found
| Shape | Direction | What is lost or gained |
|---|---|---|
try <expr> else_none | result → option | The error is discarded; why it failed is no longer said |
try <expr> else_error <variant> | option → result | Absence gets a name |
Table 11.1 — Changing channel with a try tail
A try with a tail also changes the type. The type of try (halve a) else_none is option u8, not u8. That is why maybe_half returns it as is, and why putting it into a value type, as in let v u8 be try … else_error …, is rejected.
else_none is a choice that throws information away. It is convenient, so it easily becomes a habit, but from that moment the caller can no longer ask “why”. Throw it away only where it is worth throwing away.
11.5 Three ways to report failure#
| What | What it says | What the caller does |
|---|---|---|
result t e | A failure that can be fixed | Asks which it is and handles it, or passes it up |
option t | There is no value | Asks whether it exists and takes it, or gives a fallback |
panic · contract violation | A promise was broken | Cannot handle it; the program stops |
Table 11.2 — Three ways to report failure
The question that separates the three is “what can the caller do?”. If a file is missing, another file can be tried, so it is a result. If what you look for is not in the list, it simply is not there, so it is an option. If the caller broke a contract, the promise is already broken and cannot be fixed, so execution stops. panic does not unwind; there is no way to catch it midway and carry on. How to design an op’s failures on this criterion is revisited in chapter 17.
11.6 Combining and nesting patterns#
Now that option, result and enum have all appeared, match patterns can be used more widely.
examples/ch11/patterns.low
module patterns .
rem run: warm 1
rem run: combine 1
rem run: pick 1
rem run: choose
rem run: half 200
enum color do
red .
green .
blue .
end
enum node do
lit v u32 .
plus l u32 r u32 .
times l u32 r u32 .
end
rem any one match takes the arm --- red, green and blue are all covered, so no _ is needed
fn warm input k u8 . output u8 . do
let c color be green .
match c do
case red or green . do return 1 . end
case blue . do return 2 . end
end
end
rem arms joined with or must bind the same names
fn combine input k u8 . output u32 . do
let e node be node.times 6 7 .
match e do
case plus l r or times l r . do return add l r . end
case lit v . do return v . end
end
end
rem splits an option inside a result in one go
fn pick input k u8 . output u32 . do
let r result option u32 color be ok (some 42) .
match r do
case ok (some x) . do return x . end
case ok none . do return 0 . end
case error . do return 99 . end
end
end
rem if the value being split is a compile-time constant, it folds to the one matching arm
fn choose output u32 . do
match comptime (add 2 3) do
case 0 . do return 100 . end
case 5 . do return 500 . end
case _ . do return 999 . end
end
end
rem the two ranges cover all of u8 without gaps, so no _ is needed
fn half input b u8 . output u8 . do
match b do
case 0 to 127 . do return 0 . end
case 128 to 255 . do return 1 . end
end
end
Output
$ lowentc --run warm patterns.low 1
warm(1) = 1
$ lowentc --run combine patterns.low 1
combine(1) = 13
$ lowentc --run pick patterns.low 1
pick(1) = 42
$ lowentc --run choose patterns.low
choose() = 500
$ lowentc --run half patterns.low 200
half(200) = 1
- Or-patterns.
case red or green .is taken if either matches. Each alternative counts towards exhaustiveness, so onceblueis handled no_is needed. When or-ing variants that carry values, every alternative must bind the same names —combinetakes outlandrwhether the variant isplusortimes. Mismatched names areE-MATCH-ORBIND. - Nested patterns.
case ok (some x) .splits theoptioninside aresultin one go. Nested patterns short-circuit, so if it is notokthe inner part is never looked at. That is whyerrornever tries to take out a value and stop. - Folded at translation time. If the value being split is a translation-time constant — a literal,
comptime <expr>,config <name>— thematchfolds to the one matching arm, with no comparison at run time. Dead arms are still type-checked. That is the difference from C’s#ifdef(chapter 31). - Ranges cover a type. The two ranges in
halfcover 0 … 255 ofu8without a gap, so it is exhaustive without_. A gap isE-MATCH-INEXHAUSTIVE; overlapping ranges areE-MATCH-REDUNDANT.
An arm after _ is rejected.
examples/ch11/arm_after_wild.low
module arm_after_wild .
rem expect: E-MATCH-REDUNDANT
enum light do
lit .
dark .
end
fn name input k u8 . output u8 . do
let s light be lit .
match s do
case _ . do return 0 . end
case lit . do return 1 . end
end
end
Output
$ lowentc --check arm_after_wild.low
arm_after_wild.low:13:0 E-MATCH-REDUNDANT: this `case` comes AFTER a `_` (wildcard) arm — the wildcard already matched, so this arm can never run. A dead arm is an error here, not a warning (RFC-0020 §6.4)
_ has already taken everything, so the arm after it can never run. A dead arm is an error, not a warning — a match where each case does not appear exactly once when read hides defects.
11.7 Common mistakes#
Counter-example. Using an option in arithmetic as if it were a number
examples/ch11/mistake_optarith.low
module mistake_optarith .
rem expect: E-TYPE-RETURN
fn find input k u64 . output option u64 .
do
guard lt k 5 . else return none .
return some (mul k 10) .
end
fn plus_one input k u64 . output u64 .
do
rem ✘ an `option u64` is not a number --- ask whether it is there and take it out before adding
return add (find k) 1 .
end
Output
$ lowentc --check mistake_optarith.low
mistake_optarith.low:13:0 E-TYPE-RETURN: the returned value does not match the op's `output` — expected `u64`, found `option …`
find k does not give back a u64; it gives back “a box that may or may not hold a u64”. You cannot add 1 to a box. In other languages a null flows into the calculation and blows up much later; Lowent stops you right here with E-TYPE-RETURN. There are three fixes: supply a stand-in with value_or (find k) 0, ask with is_some and take it out with some_value, or split with match. Which one to pick depends on “what should happen when it is absent”.
Counter-example. Forgetting some in an op that returns an option
examples/ch11/mistake_nosome.low
module mistake_nosome .
rem expect: E-TYPE-RETURN
fn find input k u64 . output option u64 .
do
guard lt k 5 . else return none .
rem ✘ the value is not wrapped in `some`
return mul k 10 .
end
Output
$ lowentc --check mistake_nosome.low
mistake_nosome.low:8:0 E-TYPE-RETURN: the returned value does not match the op's `output` — expected `option …`, found `u64`
Once the head says output option u64, the value you return must be a box too. none is a box, but mul k 10 is a bare number, so this is E-TYPE-RETURN. Some languages wrap the value for you; Lowent does not. Writing return some (mul k 10) . spells out “it is there”, so the reader sees both branches.
Counter-example. Using try in an op that does not return a result
examples/ch11/mistake_trynoresult.low
module mistake_trynoresult .
rem expect: E-TRY-NORESULT
enum half_error do
too_big .
end
fn halve input a u8 . output result u8 half_error .
errors too_big gt a 200 .
do
guard le a 200 . else return error too_big .
return ok (div a 2) .
end
rem ✘ uses `try`, but this op neither returns a `result` nor has an `errors` clause
fn plus input a u8 . output u8 .
do
let v u8 be try halve a .
return add v 1 .
end
Output
$ lowentc --check mistake_trynoresult.low
mistake_trynoresult.low:18:0 E-TRY-NORESULT: `try` may only be used in an op that can RETURN that error (§6.5.8(2)): this op's output is not a `result`, so there is nowhere for the failure to go. It used to pass, and the VM then returned the ERROR VALUE in the plain output slot while the native build would not compile at all. Declare `output result <T> <E> .` (and the matching `errors` clause), or handle the failure here (`guard is_ok …` / `value_or` / a `case error` arm)
try passes failure upwards, so this op must be able to return that failure itself (§6.5.8(2)). plus returns only a u8 and has no errors clause, so it is refused with E-TRY-NORESULT. Until 2026-09-16 it passed: plus 250 printed err too_big where a u8 belongs, and the native build would not even compile. Adding a tail (else_none, else_error) or handling it here (is_ok and the rest of §6.5.8(3)) keeps the op free of result; to pass the failure up, give the head a result output and an errors clause.
examples/ch11/trynoresult_fixed.low
module trynoresult_fixed .
rem run: plus 10
rem run: plus 250
enum half_error do
too_big .
end
fn halve input a u8 . output result u8 half_error .
errors too_big gt a 200 .
do
guard le a 200 . else return error too_big .
return ok (div a 2) .
end
rem an op that passes failure upward returns a result itself and lists the errors it may pass in `errors`
fn plus input a u8 . output result u8 half_error .
errors too_big gt a 200 .
do
let v u8 be try halve a .
return ok (add v 1) .
end
Output
$ lowentc --run plus trynoresult_fixed.low 10
plus(10) = ok 6
$ lowentc --run plus trynoresult_fixed.low 250
plus(250) = err too_big
Nested patterns count towards exhaustiveness too. An outer tag is covered when its inner pattern is itself exhaustive.
examples/ch11/nestedwild.low
module nestedwild .
rem run: via_match
enum read_error do
broken .
end
fn pick input r result (option u64) read_error . output u64 .
do
rem three nested patterns are every case --- no separate `_`
match r do
case ok (some x) . return x .
case ok none . return 0 .
case error e . return 1 .
end
end
fn via_match output u64 .
do
return pick (ok (some 42)) .
end
Output
$ lowentc --run via_match nestedwild.low
via_match() = 42
ok (some x) and ok none together cover ok, and error e covers the rest, so no _ is needed. Leave one case out and it is refused with E-MATCH-INEXHAUSTIVE — better than a _ that covers nothing. A _ says nothing when a variant is added later.
A common misconception. With value_or you can still tell when a value was absent
examples/ch11/valueor_blind.low
module valueor_blind .
rem run: find_or_zero 0
rem run: find_or_zero 7
rem run: found 0
rem run: found 7
fn find input k u8 . output option u8 .
do
guard lt k 3 . else return none .
return some (mul k 10) .
end
rem slot 0 really holds 0 and slot 7 is absent --- yet both come out as 0
fn find_or_zero input k u8 . output u8 .
do
return value_or (find k) 0 .
end
rem if you need to know whether it is there, ask before covering it with a stand-in
fn found input k u8 . output bool .
do
return is_some (find k) .
end
Output
$ lowentc --run find_or_zero valueor_blind.low 0
find_or_zero(0) = 0
$ lowentc --run find_or_zero valueor_blind.low 7
find_or_zero(7) = 0
$ lowentc --run found valueor_blind.low 0
found(0) = 1
$ lowentc --run found valueor_blind.low 7
found(7) = 0
value_or covers absence with a stand-in. When the stand-in collides with a real value, the two cannot be told apart. Above, the real 0 in slot 0 and the absence in slot 7 both come out as 0. Use value_or only where “treat absent as 0” is truly fine; when presence matters, ask with is_some or match before covering it.
11.8 This chapter’s syntax at a glance#
| Shape | Meaning | Why |
|---|---|---|
output option u8 . | a value, or none | absence (null) shows in the type |
some v · none | present · absent | “present” is written too, so both branches are visible |
output result u8 e . | a value or an error (a variant of e) | failure is returned as a value — there are no exceptions |
ok v · error too_big | success · failure | which branch is written in the source |
errors too_big <condition> . | promise which error happens when | written in the contract so the caller can prepare |
is_some r · some_value r | ask whether present · take it out | taking out is partial — ask first |
is_error r · ok_value r | ask whether failed · take out the success value | same reason |
value_or r 99 | a stand-in when absent | one line, but it covers absence |
try <expr> | on failure, leave returning that error | so checks are never forgotten — like Rust’s ? |
try <expr> else_none · else_error e | result → option · option → result | changing channel shows what is lost |
case ok (some x) . · case a or b . | nested pattern · several variants at once | split in one go, still covering every case |
Table 11.3 — Syntax of answer-carrying types — shape · meaning · why it looks this way
Recap
option is made with some and none, result with ok and error. The receiver asks and takes out (is_some, some_value, is_error, ok_value), gives a fallback with value_or, or splits with match. Taking out is partial, so taking from the missing side stops. An op returning a result promises its errors in an errors clause, try passes failures up, and the else_none and else_error tails change channel and type. Patterns can be or-ed (binding the same names) and nested, and a match on a constant folds at translation.