Results
Sometimes a function can fail and the caller needs to know why. T? is the cheap answer when absence is enough. It can't carry a reason. result<T, E> is a value of type T, or a failure of type E.
function double(int32 $n) : result<int32, string>
{
if ($n < 0) {
return .error('negative');
}
return .ok($n * 2);
}
int32 $n = guard double(21) else {
die('nope');
}
echo $n; // 42The return type says the call can fail. The compiler won't let you read the value without dealing with that. guard is how you deal with it here. try passes the same failure to a caller that also returns a result.
T?
A missing value with no story behind it is still a T?. A key that is not in a map, std::env::var when the name is unset. There is no reason to invent one.
Reach for result<T, E> when the reason is the point: a parser that saw a bad digit, a port that was out of range, a string that was empty versus one that was too long. The E can be a string, an int32, or an enum of your own. Anything.
Nullability is the T? chapter. This one is the type that carries a reason.
How you build one
Two cases, two constructors. ok holds the value. error holds the failure.
result<int32, string> $good = .ok(7);
result<int32, string> $bad = .error('nope');
echo $good->has_value(); // 1
echo $bad->failed(); // 1A function that can fail and has nothing to hand back should return status<E>, not result<bool, E>. void is not a type argument, so there is no result<void, E>.
Writing the type twice gets old, and you don't have to. Wherever the destination already names the type, drop the owner:
function halve(int32 $n) : result<int32, string>
{
if ($n % 2 != 0) {
return .error('odd');
}
return .ok($n / 2);
}
echo halve(10)->or(-1); // 5
echo halve(7)->or(-1); // -1A return type is a destination. So is a declared variable, and so is a parameter of a call the compiler has already resolved:
result<int32, string> $declared = .error('from a declaration');
function takes(result<int32, string> $r) : int32
{
return $r->or(-1);
}
echo $declared->failure(); // from a declaration
echo takes(.ok(7)); // 7
echo takes(.error('from an argument')); // -1What it can never be is an unknown. .ok(5) on its own has nothing to fill the owner in from, and the compiler says so rather than guessing:
$x = .ok(5);
// error: '.ok(...)' takes its type from where its value goes, and nothing here says what that isE is named nowhere in ok's signature. The owner at the call site is what binds it, which is why result<int32, string>::ok(1) and result<int32, int64>::ok(1) are two different functions and both compile.
How you read one
guard is the shape you want almost always. It declares the variable when you want the value. Leave the else off and a failure stops the program, with E in the message when str::from can print it. Write an else when you want to handle the reason; that block has to leave: return, break, continue or die. That's what makes the declaration safe afterwards.
When the else arm would have been return .error($e) and this function already returns a result, try is that arm.
When the value is not the point (the call worked, or it did not), drop the binding:
function start(int32 $n) : result<int32, string>
{
if ($n < 0) {
return .error('negative');
}
return .ok($n);
}
function go(int32 $n) : void
{
guard start($n) else ($e) {
die($e);
}
}
go(1);function first_word(const string& $line) : result<string, int32>
{
if ($line->empty()) {
return .error(-1);
}
return .ok(str::trim($line));
}
string $word = guard first_word(' hello ') else {
die('empty line');
}
echo $word; // helloAdd a name and you get the E. This is the half T? can't do, and it's why this type exists:
function pick(int32 $i) : result<string, int32>
{
if ($i < 0) {
return .error($i);
}
return .ok('ok');
}
function describe(int32 $i) : string
{
string $s = guard pick($i) else ($code) {
return "failed with {$code}";
}
return $s;
}
echo describe(1); // ok
echo describe(-3); // failed with -3$code is a borrow of the failure inside the result, seeded into the else block's own scope the same way a foreach binding is. It's only in scope inside the block.
The binding guard hands you is a copy of the payload, taken out of storage the result still owns. That's why an owning T is safe here: nothing is moved out of a value that is about to be destroyed, and nothing is aliased into storage that is about to go away.
Nullability has the rest of guard, including the rule that the else has to leave.
Fallbacks
or is the const read. It copies the value out, or the fallback you passed:
function status(int32 $n) : result<int32, string>
{
if ($n == 0) {
return .error('zero');
}
return .ok($n * 10);
}
echo status(4)->or(-1); // 40
echo status(0)->or(-1); // -1That works on a temporary, because a copy doesn't need the result to outlive the statement. unwrap() is a borrow, and that's the next section.
match
It's an enum. Nothing stops you treating it as one, and the leading-dot shorthand works in a pattern too once the subject has named the type:
function status(int32 $n) : result<int32, string>
{
if ($n == 0) {
return .error('zero');
}
return .ok($n * 10);
}
function describe(int32 $n) : string
{
result<int32, string> $r = status($n);
return match ($r) {
.ok($v) => "value {$v}",
.error($e) => "error {$e}",
};
}
echo describe(4); // value 40
echo describe(0); // error zeroguard is shorter for the common case. match is what you want when both arms produce something. See Enums for exhaustiveness, bindings, and the rule that a match over a call result cannot hand back a place. That last one is why unwrap() is a method on the result rather than a match (f()) you write at the call site.
The methods, by hand
guard is the two interfaces spelled for you. When you want the pieces:
result<int32, string> $r = .ok(41);
echo $r->has_value(); // 1
echo $r->failed(); // 0
echo $r->unwrap() + 1; // 42
echo $r->or(-1); // 41
result<int32, string> $e = .error('bad');
echo $e->failed(); // 1
echo $e->failure(); // bad
echo $e->or(-1); // -1unwrap() and failure() are each valid on one arm only. Call the wrong one and the program stops:
result<int32, string> $e = .error('bad');
echo $e->unwrap();fatal error: unwrap() on a result holding a failureThat's deliberate. The protocol asks has_value() first, so by the time guard calls unwrap() the test is already done and there is nothing left to re-check. A call you write by hand can get the order wrong, and then stopping is the only honest answer: the error arm has no T to hand back.
unwrap()
unwrap() : T& returns a reference, not a copy. A write through it lands in the result itself:
result<int32, string> $r = .ok(41);
int32& $slot = $r->unwrap();
$slot = 9;
echo $r->unwrap(); // 9Same for a method call. $r->unwrap()->push(4) appends to the array the result is holding.
Two things that look like they should work, and don't.
A call is not an assignment destination, so $r->unwrap() = 99; does not parse. Read through the borrow or call through it. To replace the whole payload, build a new result.
A named borrow out of a temporary is refused, because the result would be gone at the semicolon:
int32& $x = result<int32, string>::ok(5)->unwrap();
// error: 'result<int32,string>' has no storage of its own, so the pointer in it
// would be an address into a value destroyed at the end of this statement.
// Bind it to a variable first.Bind the payload by value and the temporary moves:
int32 $n = result<int32, string>::ok(5)->unwrap();
echo $n; // 5That's the same split a fluent chain lives under. A T& you stash past the result is yours to keep straight. See Pointers and references and Ownership and moving.
or is the copy, and that's why it's the one that works on a const result. unwrap() cannot be const. Handing a writable borrow out of a read-only value would be a lie.
E can be an enum
A string is fine for a first draft. Once the set of failures is closed, say so:
enum ParseError
{
case empty;
case out_of_range;
}
function parse_port(const string& $text) : result<int32, ParseError>
{
int64 $n = guard str::int($text) else {
return .error(.empty);
}
if ($n < 1 || $n > 65535) {
return .error(.out_of_range);
}
return .ok($n);
}
function describe(const string& $text) : string
{
int32 $port = guard parse_port($text) else ($why) {
return match ($why) {
.empty => 'not a number',
.out_of_range => 'out of range',
};
}
return str::from($port);
}
echo describe('8080'); // 8080
echo describe('nope'); // not a number
echo describe('70000'); // out of range.error(.empty) is the destination shorthand twice: the return type names result<int32, ParseError>, and that names ParseError for the argument. Enums is the chapter for the cases.
No compiler arm
result<T, E> is ordinary Echo in stdlib/core/result.eco. There is no #[echoc::core:] binding on it, no node for it, no pass that mentions it. guard reaches it through two interfaces any type of yours can declare, and .ok(...) / .error(...) are the same static shorthand every enum's cases get.
So you can write your own. result<T, E> is not a privileged type. It's the one the library happens to ship. Contracts is the two interfaces, and Nullability walks a type that declares them.
I prefer it this way. A language feature would have been a second T? with a payload bolted on, and then every rule about T? would have needed a twin. An enum in the library means match already works, guard already works, and a type of yours that answers the same two questions guards exactly as well.
Why an enum
A struct could do this. Hold a bool $ok beside a T and an E, and every function above is writable.
The first problem is the constructors. result<string, string> has a value of one type on both arms. Two hand-written constructors (T) and (E) are one signature here and collide. As an enum the two cases are told apart by name, so the question never arises.
The second problem is what a copy costs. Echo synthesizes a struct's copy and its teardown over every property, so a result holding a value would still retain and release the failure it is not holding. For result<string, string> that's two reference counts moving per copy where one should. And a struct cannot opt out: declare a destructor to branch by hand and the copy rules answer "no copy at all", so the type stops being copyable.
An enum's copy and teardown are per case by construction. An ok touches the value slot and never looks at the failure slot. That's the entire argument, and it's why the language grew a way to hand a payload back as a T& rather than this type growing a bool.
What it costs
The two payloads overlay, so result<T, E> is as wide as the larger of T and E, plus the tag and the padding their alignment wants. It is not as wide as both at once.
echo mem::size<result<int64, int32>>(); // 16
echo mem::size<int64>(); // 8
echo mem::size<int32>(); // 416 is not 8 + 4. The tag is one byte, int64 wants 8-byte alignment, and the int32 sits in the same eight bytes the int64 uses. A layout that sat the two payloads side by side would be 24.
Enums is the rest of the shape: the slots are still ordinary properties, which is what keeps copy and teardown per case with no special rule for this type.
A result you produce and immediately guard is usually inlined down to a test of the tag, so the width never becomes a copy you pay for. A result you store, put in an array, or hand across a function that does not inline is the 16-byte value above.
Surface
| Function | Answers | Valid when |
|---|---|---|
result<T, E>::ok(T $value) | a result holding a value | always |
result<T, E>::error(E $failure) | a result holding a failure | always |
const function has_value() : bool | is there a value | always |
const function failed() : bool | the complement of has_value() | always |
function unwrap() : T& | a borrow of the value | has_value() is true, dies otherwise |
function failure() : E& | a borrow of the failure | failed() is true, dies otherwise |
const function or(T $fallback) : T | a copy of the value, or $fallback | always |
has_value() and unwrap() are contract::unwrappable<T>. failure() is contract::failable<E>. Those two are the whole of what guard needs.
Next
- Nullability for
T?,guard, and writing a typeguardaccepts. - Try for passing the failure to a caller that also returns a result.
- Contracts for the two interfaces, in full.
- Enums for cases, payloads,
match, and the layout. - Errors and panics for where a result sits among
assertanddie.