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 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. . 99 .gives 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 vandcase 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 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 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 idx 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 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 Binding else — take the content, or leave#
When you handle a failure right here instead of passing it up, the check-then-take-out pair of lines can be one line. Write the binding’s type as the content’s type and attach what to do when it is empty with else.
examples/ch11/bindelse.low
module bindelse .
rem run: lookup 7
rem run: lookup 4
rem run: doubled 30
rem run: doubled 300
enum perr do
too_big .
end .
fn find input xs slice u64 . input k u64 . output option u64 . do
var i u64 0 .
for x xs. do
if eq x. k. . do
return some i. . .
end .
set i. add i. 1 . .
end .
return none .
end .
fn twice input a u64 . output result u64 perr .
errors too_big ge a. 100 . .
do
guard lt a. 100 . else return error too_big . . .
return ok mul a. 2 . . .
end .
rem take the content out of an option --- leave through else when it is empty
export fn lookup input k u64 . output u64 .
do
let xs slice u64 lit slice u64 5 6 7 . .
let at u64 find xs. k. . else return 99 . .
return add at. 100 . .
end .
rem a result works the same way --- an error goes to else
export fn doubled input a u64 . output u64 .
do
let v u64 twice a. . else return 0 . .
return add v. 1 . .
end .
Output
$ lowentc --run lookup bindelse.low 7
lookup(7) = 102
$ lowentc --run lookup bindelse.low 4
lookup(4) = 99
$ lowentc --run doubled bindelse.low 30
doubled(30) = 61
$ lowentc --run doubled bindelse.low 300
doubled(300) = 0
let at u64 find xs. k. . else return 99 . .—findreturns anoption u64. If there is a value, thatu64is bound toat; if not, control goes toelse. Aresultworks the same way — an error goes toelse.elsemust leave (return·break·continue·panic). So on the lines that useatthe value has already been taken out, and using it unchecked cannot happen.- Where stopping is acceptable, write
else panic "…" .. The place that may stop is visible once in the source, and in a purefnthe effect rules refuse it. - Binding an
optionas its content withoutelseis refused — the diagnostic tells you to addelseor to declare the bindingoption …. Forgetting that something can fail is caught at translation. - To pass the error up unchanged, use
try. Useelsewhen you want to handle it differently here.
What follows else is written in one of three ways. A single leaving statement is else return 99 . — the statement’s full stop comes at the very end. Several statements are else do … end — the block ends with end, so there is no full stop. To see why a result failed, bind the error value to a name with else error <name> do … end.
examples/ch11/elseerror.low
module elseerror .
rem run: port 8080
rem run: port 0
rem run: port 70000
enum port_error do
zero .
too_big .
end .
fn check input n u64 . output result u64 port_error .
errors zero eq n. 0 . .
errors too_big gt n. 65535 . .
do
if eq n. 0 . do return error zero . . end .
if gt n. 65535 . do return error too_big . . end .
return ok n. . .
end .
rem look at *why* it failed --- `e` lives only inside this block
export fn port input n u64 . output u64 .
do
let p u64 check n. . else error e do
if eq e. zero. . do return 80 . end .
return 65535 .
end .
return p. .
end .
Output
$ lowentc --run port elseerror.low 8080
port(8080) = 8080
$ lowentc --run port elseerror.low 0
port(0) = 80
$ lowentc --run port elseerror.low 70000
port(70000) = 65535
let p u64 check n. . else error e do … end .— whencheckreturns an error, that error value is bound toeand the block runs. Every path of the block must leave.elives only inside the block.port 0iszero, so the answer is 80;port 70000istoo_big, so it is 65535. When the value is anoptionthere is no error to bind, andelse error <name>is refused withE-BIND-ELSE.
11.5 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 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.6 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.7 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
num 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 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 node.times 6 7 . .
match e. do
case plus l r or times l r do return add l. r. . . end .
case num 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 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 greenis 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
bright .
dark .
end .
fn name input k u8 . output u8 . do
let s light bright. .
match s. do
case _ do return 0 . end .
case bright 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.8 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 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 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) do return x. . end .
case ok none do return 0 . end .
case error e do return 1 . end .
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.9 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 ? |
let n u64 find xs. k. . else return 0 . . | take the content, or leave through else | no unchecked use |
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.