Every modern programming language has error handling. Like Python, Dart chose the unchecked try-catch catch pattern, Java went with checked try-catch pattern, Zig went with Error Unions, and Rust went the Result
type.
What Is a Result Monad Type And Why Use it?
Result
is often referred to as a “monad”. A monad is just a wrapper around an object that provides a standard way of interacting with the inner object. The
Result
monad is used in place of throwing exceptions. Instead, the function
returns a Result
, which can either be a Ok
(Success) or Err
(Error/Failure), Result
is the sum type of the two.
Using Result
provides a predictable control flow to a program. Error handling is explicit as the caller of a function
that returns an error must handle the possibility of an error at that point. At which the point, the calling function can resolve
any possible issue or clean its state, where necessary, or pass the error up the chain until a function resolves
the issue.
The Problem With Dart Error Handling
The best way to illustrate the problem with Dart’s error handling is an example. Consider the following program, which doesn’t do anything useful, but we are more interested in the control flow:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void main() {
// try {
print(order("Bob", 1));
// } catch(e) {
// print(e);
// }
}
String order(String user, int orderNumber) {
final result = makeFood(orderNumber);
return "Order of $result is complete for $user";
}
String makeFood(int orderNumber) {
return switch(orderNumber) {
1 => makeHamburger(),
2 => makePasta(),
// Who catches this??
// How do we know we won't forget to catch this??
_ => throw Exception("Unknown order number '$orderNumber'."),
};
}
String makeHamburger() => "Hamburger";
String makePasta() => "Pasta";
There are several issues with this program:
- If we forget to catch in the correct spot, we just introduced a bug or worse - crashed our entire program.
- We may later reuse
makeFood
ororder
, and forget that it can throw. - The more we reuse functions that can throw, the less maintainable and error-prone our program becomes.
- Throwing is also an expensive operation, as it requires stack unwinding.
Result Type
Throughout the rest of this article we will be looking at the Result
type in Dart as provided by the rust_core
package.
Here is the previous example implemented with Result
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import 'package:rust_core/result.dart';
void main() {
print(order("Bob", 1));
}
Result<String, Exception> order(String user, int orderNumber) {
final result = makeFood(orderNumber);
if(result case Ok(:final ok)) { // Could also use "if(result.isOk())" or a switch statement
return Ok("Order of $ok is complete for $user");
}
return result;
}
Result<String, Exception> makeFood(int orderNumber) {
return switch(orderNumber) {
1 => Ok(makeHamburger()),
2 => Ok(makePasta()),
_ => Err(Exception("Unknown order number '$orderNumber'.")),
};
}
String makeHamburger() => "Hamburger";
String makePasta() => "Pasta";
By using Result
, we have removed all undefined behaviors due to control flow! Thus eliminating
all previously mentioned issues.
You can interact with Result
through conventions like
case Ok(:final ok)
and isOk()
, or methods like .map(..)
, .andThen(..)
, etc. Checking allows you to
either resolve any potential issues in the calling function or pass the error up the chain until a function resolves
the issue. This provides predictable control flow to your program, eliminating many potential bugs and countless
hours of debugging.
Conclusion
In conclusion, adopting the Result
type for error handling in Dart brings numerous benefits, especially in creating
predictable and maintainable control flows. Unlike traditional try-catch mechanisms, Result
ensures that error
handling is explicit and unavoidable. Thus preventing potential bugs and improving code reliability.
The rust_core package’s implementation
of Result
in Dart provides a powerful tool for managing errors effectively,
aligning Dart’s error handling capabilities with those of more modern and robust systems like Rust.
Embracing this approach will not only enhance the stability of Dart applications but also streamline the
development process, making it easier to maintain and debug code in the long run.