Lowent Manual←↑→

11 Types that hold answers — option and result

What to know first

chapter 7, Flow · guard and panic
chapter 10, Aggregates · enums carrying values, and match

Looking 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

The habit of signalling failure with a special value such as −1 or a null pointer makes it impossible to tell from the source whether “this −1 is an error or just −1”. And forgetting the check goes unnoticed. 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

You will learn to make an 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

  1. 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_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

Java’s or C++‘s 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
ShapeDirectionWhat is lost or gained
try <expr> else_noneresult → optionThe error is discarded; why it failed is no longer said
try <expr> else_error <variant>option → resultAbsence 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#

WhatWhat it saysWhat the caller does
result t eA failure that can be fixedAsks which it is and handles it, or passes it up
option tThere is no valueAsks whether it exists and takes it, or gives a fallback
panic · contract violationA promise was brokenCannot 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

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#

ShapeMeaningWhy
output option u8 .a value, or noneabsence (null) shows in the type
some v · nonepresent · 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_bigsuccess · failurewhich branch is written in the source
errors too_big <condition> .promise which error happens whenwritten in the contract so the caller can prepare
is_some r · some_value rask whether present · take it outtaking out is partial — ask first
is_error r · ok_value rask whether failed · take out the success valuesame reason
value_or r 99a stand-in when absentone line, but it covers absence
try <expr>on failure, leave returning that errorso checks are never forgotten — like Rust’s ?
try <expr> else_none · else_error eresult → option · option → resultchanging channel shows what is lost
case ok (some x) . · case a or b .nested pattern · several variants at oncesplit 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.