Error handling with Result
An operation that can fail returns a Result: either Ok, holding the value
you asked for, or Err, holding an error that says what went wrong. The
caller checks which one it got before using the value. Pepper has no
exceptions, so Result is how a function reports a failure the caller is
expected to handle. For a failure that should stop the program, such as a
broken invariant, use assert or panic instead.
Result and Error are part of the runtime, so you can use them
without an import. The Result reference lists every
member.
Creating and checking results
Result<TValue, TError> works like Rust’s Result<T, E>. It is an
enum with two cases, Ok(value: TValue) and Err(error: TError).
You build one with a case or with the static functions ok and err, and
read it with match, is,
or its methods:
# A function that can fail returns a Result instead of a plain value.
parse_digit(String s): Result<int> {
if (s == "0") { return Result<int>.ok(0); }
if (s == "1") { return Result<int>.ok(1); }
if (s == "2") { return Result<int>.ok(2); }
return Result<int>.err(Error("not a digit: " + s));
}
main(): int {
var good = parse_digit("2");
assert(good.is_ok(), "2 parses");
assert(!good.is_err(), "is_err is the opposite of is_ok");
assert(good.value() == 2, "value() returns the value");
var bad = parse_digit("x");
assert(bad.is_err(), "x does not parse");
assert(bad.error().message == "not a digit: x", "the error explains why");
# ok_or returns the value, or the fallback for an Err. It never panics.
assert(good.ok_or(-1) == 2, "Ok gives its value");
assert(bad.ok_or(-1) == -1, "Err gives the fallback");
# Result<int> is short for Result<int, Error>.
Result<int, Error> spelled_out = good;
assert(spelled_out.value() == 2, "the same type");
# Result is an enum: its cases build values too, and match reads them.
var parsed = Result<int>.Ok(7);
var doubled = match (parsed) { Ok(n) => n * 2, Err(e) => 0 };
assert(doubled == 14, "match binds the value");
if (bad is Err(e)) {
assert(e.message.length > 0, "is binds the error");
}
# An operation with no value to return uses void.
var done = Result<void>.ok();
var failed = Result<void>.err(Error("disk full"));
assert(done.is_ok() && failed.is_err(), "Result<void> works like any other");
return 0;
}
The pieces:
| Member | What it does |
|---|---|
Result<T>.Ok(value), Result<T>.ok(value) | Creates an Ok result holding value. |
Result<T>.Err(error), Result<T>.err(error) | Creates an Err result holding error. |
is_ok(), is_err() | Report which kind the result is. |
value() | Returns the value; panics with Result.value on an Err value if the result is an Err. |
error() | Returns the error; panics with Result.error on an Ok value if the result is Ok. |
ok_or(fallback) | Returns the value, or fallback if the result is an Err. Never panics. |
TError defaults to Error, so Result<int> means Result<int, Error>, and
values of the two spellings can be assigned to each other freely (the compiler
does generate a separate specialization for each spelling). Error is a struct
with one field, String message: Error("boom").message is "boom".
Use value() when an Err would be a bug in your program, since it stops the
program. When an Err is an expected outcome, match on the result, test it
with is (if (r is Err(e)) { ... }) or is_ok(), or supply a fallback with
ok_or.
Check before you read
A Result holds either its value or its error, never both. A match or an
is test reads the one it holds, so it cannot pick the wrong one. value()
and error() panic on the wrong one, so call value() only after checking
is_ok(), and error() only after checking is_err().
Result is a value enum: it is stored inline in
whatever holds it, so making one allocates nothing, and it is never null. A
slot of type Result that is never given a value holds its first case,
Ok, carrying the zero value of TValue (0, or null for a struct), so give
every result slot a value. Result<int>() is an error, since only the cases
make values.
Results with no value
An operation that has nothing to return on success, such as deleting a file,
uses void as its value type, like Result<(), E> in Rust. A void type
argument erases what it types, so in Result<void> the case Ok carries
nothing, ok() takes no argument, and value() returns nothing: calling it
only checks that the result is Ok. Using that call as a value, as in
var x = done.value();, is a compile error, as it is for any function that
returns void.
var done = Result<void>.Ok; # or Result<void>.ok()
var failed = Result<void>.err(Error("disk full"));
done.value(); # panics if `done` is an Err
match (failed) {
Ok => {}
Err(e) => { print(e.message); }
}
Custom error types and propagation
Any type can be the error type. A struct lets you carry details the caller can act on, not just a message. To pass an error up to your own caller, check the result and return early with its error; to change the error’s type on the way, build a new error from the old one:
# A custom error type: any type can be the second type argument.
struct ParseError {
String input;
int position;
construct(this.input = "", this.position = 0) {}
}
digit_value(String c): int {
var digits = "0123456789";
var i = 0;
while (i < 10) {
if (c == digits[i]) {
return i;
}
i = i + 1;
}
return -1;
}
parse_number(String s): Result<int, ParseError> {
if (s.length == 0) {
return Result<int, ParseError>.err(ParseError(s, 0));
}
var value = 0;
var position = 0;
for (var c in s) {
var d = digit_value(c);
if (d < 0) {
return Result<int, ParseError>.err(ParseError(s, position));
}
value = value * 10 + d;
position = position + 1;
}
return Result<int, ParseError>.ok(value);
}
# Propagate an error: check it, and return early with the same error.
add_numbers(String a, String b): Result<int, ParseError> {
var x = parse_number(a);
if (x.is_err()) {
return Result<int, ParseError>.err(x.error());
}
var y = parse_number(b);
if (y.is_err()) {
return Result<int, ParseError>.err(y.error());
}
return Result<int, ParseError>.ok(x.value() + y.value());
}
# Convert an error into another type on the way out.
check_total(String a, String b, int limit): Result<void> {
var sum = add_numbers(a, b);
if (sum.is_err()) {
var e = sum.error();
return Result<void>.err(Error("bad number '" + e.input + "'"));
}
if (sum.value() > limit) {
return Result<void>.err(Error("total is over the limit"));
}
return Result<void>.ok();
}
main(): int {
var sum = add_numbers("40", "2");
assert(sum.value() == 42, "40 + 2");
var bad = add_numbers("40", "4x2");
assert(bad.is_err(), "the second number is bad");
assert(bad.error().input == "4x2", "the error names the input");
assert(bad.error().position == 1, "and where it went wrong");
assert(check_total("1", "2", 10).is_ok(), "3 is within the limit");
var over = check_total("10", "20", 10);
assert(over.error().message == "total is over the limit", "30 is not");
var wrong = check_total("1", "", 10);
assert(wrong.error().message == "bad number ''", "an empty number is an error");
return 0;
}
The ? operator, below, writes each of these checks in one character.
Propagating errors with ?
? after a Result passes its error up, like Rust’s ?: on an Ok result
it is the value, and on an Err result it returns that error from the
enclosing function at once. var x = parse(s)?; does what the if (x.is_err()) { return ...err(x.error()); } checks above do:
# `?` propagates a failure: on an Err it returns that error from the
# enclosing function, and on an Ok it is the value.
struct ParseError {
String input;
construct(this.input = "") {}
}
parse_number(text: String): Result<int, ParseError> {
if (text.is_empty) {
return Result<int, ParseError>.err(ParseError(text));
}
var value = 0;
for (var c in text) {
var digit = "0123456789".find(c);
if (digit < 0) {
return Result<int, ParseError>.err(ParseError(text));
}
value = value * 10 + (digit as int);
}
return Result<int, ParseError>.ok(value);
}
# Each `?` returns early with the error, so the sum is computed only when
# both numbers parse.
add_numbers(a: String, b: String): Result<int, ParseError> {
return Result<int, ParseError>.ok(parse_number(a)? + parse_number(b)?);
}
# A function with a different error type: the `[Implicit]` constructor
# converts a ParseError on its way out.
struct ConfigError {
String message;
[Implicit]
construct(e: ParseError) {
this.message = "bad number '" + e.input + "'";
}
}
read_limit(text: String): Result<int, ConfigError> {
var limit = parse_number(text)?;
if (limit == 0) {
return Result<int, ConfigError>.err(ConfigError(ParseError(text)));
}
return Result<int, ConfigError>.ok(limit);
}
first_name(names: Array<String>): String? {
if (names.is_empty) {
return Optional<String>.Empty;
}
return Optional<String>.Value(names[0]);
}
# On an Optional, `?` returns an Empty optional from a function that returns
# one.
first_length(names: Array<String>): ulong? {
return Optional<ulong>.Value(first_name(names)?.length);
}
main(): int {
assert(add_numbers("40", "2").value() == 42, "40 + 2");
assert(add_numbers("40", "4x2").error().input == "4x2", "the error is the second number's");
assert(read_limit("10").value() == 10, "a limit");
assert(read_limit("ten").error().message == "bad number 'ten'", "converted to a ConfigError");
assert(first_length(["Ada", "Grace"]).value() == 3, "Ada");
assert(first_length(Array<String>()).is_empty(), "no names");
return 0;
}
The rules:
- The enclosing function must declare that it returns a
Result, or the compiler reports'?' requires the enclosing function to return a Result. A closure that uses?writes its return type out:(s: String): Result<int> => Result<int>.ok(parse(s)? + 1). - The error type must be the function’s error type, or convert to it
through an
[Implicit]constructor (see Implicit conversions), asConfigErrordoes above, like Rust’sFrom. Otherwise the error iscannot convert error type 'ParseError' to 'ConfigError'. - On a
Result<void>,?yields nothing, so it stands as a statement:remove(path)?;. - On an
Optional,?works the same way in a function that returns an optional: an Empty optional returns Empty (see Optional values). - The early return is an ordinary
return: locals are released andwithblocks are exited as for any other.
Each ? in a statement runs before the rest of the statement, so two
placements are errors:
- After a call in the same statement, as in
f() + g()?, whereg()would run beforef():'?' cannot follow a call in the same statement. Callf()in a statement of its own first. Reading variables and fields before a?is fine, and several?s run in the order written:add(parse(a)?, parse(b)?). - Where it might not run: the right operand of
&&or||, a branch of?:, or amatcharm. Use aniformatchstatement there.
A ? in a while condition runs on every iteration, before the condition
is tested.
Results from the io module
The io module is a good model of a library built
on Result. Every operation that can fail returns an IoResult<T>, a type
alias for Result<T, IoError>, and operations with nothing to return use
IoResult<void>. IoError holds the platform’s error code and a message.
To decide what to do about the failure, kind() returns the code’s portable
ErrorKind (NotFound, PermissionDenied, AlreadyExists, …,
Other), an enum to match on, and a predicate per kind, such as
is_not_found() and is_permission_denied(), tests for one of them:
import [File, Directory, Path, IoResult, IoError, ErrorKind] from io;
# IoResult<T> is Result<T, IoError>: the same methods, a richer error.
read_setting(Path path): String {
var text = File.read_to_string(path);
if (text.is_err()) {
if (text.error().is_not_found()) {
return "default";
}
return "error: " + text.error().message;
}
return text.value();
}
# kind() sorts the platform's code into an ErrorKind, for a match.
describe(error: IoError): String {
return match (error.kind()) {
NotFound => "missing",
PermissionDenied => "not allowed",
else => error.message,
};
}
main(): int {
var dir = Directory.temp().join("pepper-results-io-example");
var created = Directory.create_all(dir);
assert(created.is_ok(), "created the folder");
var path = dir.join("setting.txt");
assert(read_setting(path) == "default", "a missing file gives the default");
var missing = File.open(path);
assert(missing.is_err() && missing.error().kind() == ErrorKind.NotFound, "the kind of the error");
assert(describe(missing.error()) == "missing", "a match on the kind");
IoResult<void> written = File.write_text(path, "blue");
assert(written.is_ok(), "wrote the file");
assert(read_setting(path) == "blue", "the file's contents");
assert(Directory.remove_all(dir).is_ok(), "cleaned up");
return 0;
}
See io: errors and platform types for the full list of kinds and the platform codes each one covers.
A library that reads a file format reports damaged data the same way:
IoError.invalid_input(message) makes an error whose is_invalid_input()
holds, as compress and archive return for corrupt input.
Reading a directory or a file line by line in a for loop yields one
IoResult per element, so each error is handled where it happens; see
Iteration.