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 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 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

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 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

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

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
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 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#

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

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#

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_big .success · 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 r. .ask whether present · take it outtaking out is partial — ask first
is_error r. . · ok_value r. .ask whether failed · take out the success valuesame reason
value_or r. 99 .a stand-in when absentone line, but it covers absence
try <expr>on failure, leave returning that errorso checks are never forgotten — like Rust’s ?
let n u64 find xs. k. . else return 0 . .take the content, or leave through elseno unchecked use
try <expr> else_none · else_error eresult → option · option → resultchanging channel shows what is lost
case ok (some x) · case a or bnested 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.