Failure is a value
There is no null, and there are no exceptions.
An operation may find no value or fail. Many languages represent these cases with null, which means no value, or exceptions, which interrupt normal execution. If the code does not handle them, the problem may only appear when the program runs.
Heroes uses a type that represents both outcomes: a value or an error. It has no null or exceptions. A function’s declaration tells you whether it can fail, without requiring you to read its body.
A type that means “or an error”
i64? means an integer or an error. Before using the integer, you must handle the possibility of failure. In a function with this return type, ok(v) returns a value and fail(code, msg) returns an error:
# Parses a decimal digit. Fails on anything else.
function digit_of(c: u8) -> i64?
if c < '0' || c > '9'
return fail(code: "not_a_digit", msg: "expected 0-9")
return ok(to_i64(c - '0').must())
The error contains a code and a message. The code, such as "not_a_digit", is a stable name in snake_case: lowercase words separated by underscores. The program uses it to distinguish errors and decide how to handle them. The message explains the problem to the reader and can be improved without changing the code.
Returning an error to the caller: ?
To return an error to the caller without handling it here, add ? to the call. The symbol marks the point where the function can return early:
# Reads a whole non-negative number. Empty input is an error, not a zero.
function number_of(text: str) -> i64?
if text.len() == 0
return fail(code: "empty", msg: "there is nothing to read")
total: i64 @= 0
for i in range(from: 0, to: text.len())
total @ total * 10 + digit_of(text[i])?
return ok(total)
digit_of(text[i])? calls digit_of. If the result is an error, it immediately exits the current function and returns that error to the caller. Otherwise, it uses the returned number and continues the loop.
The example also uses a mutable variable, a range, and calls with a dot.
total: i64 @= 0 declares a mutable variable. The line total @ total * 10 + … assigns a new value to it. The symbol = binds a name that cannot be reassigned. Bindings and mutation →
range(from: 0, to: text.len()) counts from from up
to but not including to. Both argument names are
mandatory, because both arguments are i64.
The rule, and why →
text.len() and len(text) are the exact same
call, written two ways. Writing it with a dot only helps you read left to
right when you chain several operations: no function is attached to the
type.
With ?, you can return the error without writing try and catch blocks or repeating the check after each call. In this example, the function averages a list and returns an error at the first invalid character:
function average_of(parts: [str]) -> i64?
sum: i64 @= 0
for p in parts
sum @ sum + number_of(p)?
if parts.len() == 0
return fail(code: "empty", msg: "no parts to average")
return ok(sum / parts.len())
Handling the result: match
Use match to handle the two possible results directly and access their contents:
# Four ways to read a fallible value, in the order you reach for them.
function report(text: str) -> str
match number_of(text)
.ok v => return "read " + v.to_str()
.err e => return "failed: " + e.code + " (" + e.msg + ")"
Each branch names a case with a leading dot and binds a name to its contents: .ok v makes the value available as v. The symbol _ discards contents you do not need. Both branches are required; the program will not compile if one is missing. A catch-all branch, which handles all other cases, is not allowed. This rule requires you to revisit the code if the set of possible cases changes.
When failure already has an answer
If you already know what to do on failure, you can use one of these two shorter forms:
function first_or_zero(parts: [str]) -> i64
return number_of(parts[0]).default(0)
.default(0) returns the value on success or 0 on failure. The result has type i64. .must() returns the value but stops the program if it finds an error. Use it when you have already verified that the operation succeeds, or in tests:
test "a good number and a bad one"
assert number_of("120").must() == 120
assert number_of("12x").is_err()
assert report("7") == "read 7"
assert first_or_zero(["oops"]) == 0
You can write a test in the same file as the code it checks. The heroes test command runs every test in that file.
The whole thing, running
The example file shows all four ways to handle a fallible value: ?, match, .default() and .must(). This is its main function:
function main()
print(report("42"))
print(report("4b2"))
print(average_of(["10", "20", "30"]).default(-1))
And what it prints:
$ heroes run examples/gallery/03-fallible.hero
read 42
failed: not_a_digit (expected 0-9)
20
The second line shows the error from digit_of at the b in "4b2". The ? in number_of returns it to report, preserving the original code and message.
What changes in the code
The return type tells you whether a function can fail. In calls, ? marks where an error can end the current function. To get the value, handle the error with match or .default(), or explicitly use .must() and accept that the program will stop on failure.
Next: Control flow: if,
while, for, and why a condition can only be a
bool. Back to the chapter list.
← Previous: Variants and match
Run a program of your own: it takes four commands. Or take the whole language in one file, and every program already written in it.