Result Monads in Dart and Flutter

One of the idioms that I started using for function returns was the Result Monad. I first got excited about it when learning Rust, where this is an intrinsic part of function returns. It wasn’t until reading Adam Bennett’s blog post on the Result Monad library in Kotlin that I really felt the impetus to use it in other languages. I don’t always use it, especially in quick/simple things, but for building an API/library I have found it very useful. When coming over to Dart I tried to use something similar. There were some monad libraries or the “Either” concept in functional programming libraries. None of them had the flows and syntax that I liked in my favorite Kotlin Result Monad Library except for one library which looked abandoned and had a Flutter dependency so couldn’t be used in Dart only projects. I therefore decided to write my own and publish it. This post is an exploration of that. The library source code and issue tracker is here with the pub.dev entry here .

NOTE this article is from 2021. There is updated preferred syntax in later release, as covered in this post on the 2.0 release

Background: How Do You Handle An Error?

Error handling in function/method calls is one of those things that has no one right answer, but there can be some wrong ones. The choices available to languages expands when languages support throwing exception handling. Let’s say there is a function/method that opens a file and returns it to you:

File open(String filename) {}

If everything is correct it returns a file and everything goes on fine. What happens if the path is malformed, the calling program doesn’t have access to that path, or some other error manifests? One simple answer would be to throw an exception (pseudocode):

File open(String filename) {
   ...
  // do some checks
  if (checksFail) { 
    throw FileAccessException();
  }
  ...
  
  return file;
}

In this way the calling code will have to either call this within a try-catch block to handle the error:

 try {
   final file = File.open(filename);
   print(file.read());
 } catch (e) {
  //handle the error
 }
 

If there is no try/catch block and it fails the error percolates up the stack until something does handle it or the program crashes. Either way we won’t accidentally read from a file that we don’t have access to. This is a legitimate method although there can often be performance penalties for doing flow control with exceptions, depending on the language/runtime. There is also the argument that exceptions should be saved for exceptional circumstances. By doing so it can aid in debugging since exceptions aren’t being thrown all over the place. At a low level trying to open a file you can’t is exceptional. However if this was a higher up method and you should be doing preemptive checks this would be an error state to be managed and would not be considered “exceptional”. How else could we have managed to do that if not using exceptions? We could change it so that the function returns a null if it has an error and a file only if it doesn’t. Then at the calling level we could handle the null case

File? open(String filename) { 
  ...
  if (checksFail) {
   return null;
  }
  ...
  return file;   
}

...

final file = File.open(filename);
if (file != null) {
  print(file.read());
}

This certainly can be a helpful way of propagating error but we are limited to the information we can pass up for handling. What if we could make useful decisions based on the potential type of failure, for example a malformed path versus a permissions error? In this way we’d want to write the function so it could return either the value or error information. That is exactly what the result monad does.

The Result Monad Introduction

NOTE: Although the below examples are pseudocode, it is using the syntax of the Dart Result Monad library for the monad specific aspects.

A result monad can have one of two values: a success value of one type or an error value of another type. The user of the returned monad can then query the monad to determine whether the function returned successfully or failed. They can also get the encapsulated successfully returned value or the returned error value. Using our above pseudocode example converted into the result monad form it would look like:

Result<File, FileAccessError> open(String filename) {
  ...
  if (checksFail) {
    return Result.error(FileAccessError());
  }
  ... 
  return Result.ok(file);
}

The monad supports two constructors: ok and error. The ok constructor returns a success result monad with the value returned wrapped in it. The error constructor does the same but for error states. When using the result we can then ask for either value and also intrinsically test if it succeeded:

final fileResult = File.open(filename);
if (fileResult.isSuccess) {
  print(fileResult.value.read());
} else {
  print('Error getting file ${fileResult.error}');
}

As we can see we now have the option to ask the monad if it encapsulates a successful or failed result and then get that respective value. What happens if we ask for the value on a failed object? We will get an exception. What happens if we ask for the error value on a success object? We will get an exception. At the simplest level we check whether we have the success or error condition and then get the respective value. That alone would provide some value but is a bit clunky. We can certainly do a lot better.

Get Or Else

A common workflow is to get a value back from a function call but to handle an error by returning a default value if an error condition comes back. Say for example that you want to get a pre-cached value from a service or create a new one if it comes back with none or an error. The above clunky way would look something like:

final cachedValueResult = cacheService.getValue(key);
if (cachedValueResult.isSuccess) {
  return cachedValueResult.value;
}

return objectService.getNewObject();

Instead we can compress this into one call using the getValueOrElse method. This is similar to the value property in that on a success result monad it will return the value. However for a failure monad it will return the else value you defined:

return cacheService.getValue(key)
  .getValueOrElse(()=>objectService.getNewObject());

In this way we don’t need to explicitly check for success/failure but instead the monad will do that for us and execute whichever path is appropriate. It is the ability to cleanly handle success/failure paths that make the monad powerful. There is a comparable getErrorOrElse method as well.

Match and Fold

Often times we want to handle both the success and failure paths on a Result. That error handling may just be to log an error/warning. Other times we want to build a new value that depends on either the success or failed return. The Result Monad has methods for doing each of those things. The match method provides a way to handle each of the two cases by passing in lambda functions for each of those scenarios. The fold looks similar but it creates a new value depending on the monad’s condition. Let’s look at a quick simple example back to our file example above. The first will be to get the file contents and print them or print the error code:

Result<File, FileAccessError> open(String filename) ...
...

File.open(filename)
 .match(
   onSuccess: (file) => print(file.read()),
   onError: (error) => print('Error trying to open $filename: $error')
 );

Let’s say that instead of just printing the file or an error code that we are trying to summarize the file size for another operation where an error opening should just return a value of zero:

Result<File, FileAccessError> open(String filename) ...
...

final size = File.open(filename)
 .fold(
   onSuccess: (file) => file.stat().fileSize(),
   onError: (error) => 0
 );

As we can see our code can be even more expressive and concise at the same time. Things can get even more interesting by allowing chaining of result monads with the andThen method.

“…and then?” “And no ‘and then’!”

NB: I can’t help but read my “andThen” statements without hearing this scene from the movie “Dude Where’s My Car?”, hence the title

Hardened code can often be a series of calls with checks on the results to make sure that errors didn’t occur and then short circuiting to an error result. In some traditional implementation it could look like:

final result1 = doSomething();
if (result1 == null) {
  // return null as an error
}

final result2 = result1.doSomething1();
if (result2 == null) {
  // return null as an error
}

final result3 = result2.doSomething2();
if (result3 == null) {
  // return null as an error
}

return result3.doSomething3();

Result monads by themselves can help with this to a certain extent in that you can be handling and returning actual errors not just nulls when an error arises. You can even perhaps use a series of match/folds to get some more conciseness than the above. The essence of the flow however is to just keep on trucking unless an error was generated. You want to return a generated error but otherwise just keep on moving through the instructions until you get the result. The andThen method does exactly this.

The andThen method takes a lambda expression that returns another result monad from the result of that call. It will execute that method only if it is a success monad, otherwise it just returns the original error state. So taking the above code we compress things down to:

final result1 = doSomething();
final result2 = result1.andThen((r1) => r1.doSomething1());
final result3 = result2.andThen((r2) => r2.doSomething2());
return result3.doSomething3();

However we don’t really care about the intermediate results just the final result or any error that may have been generated along the way that short circuited the execution. By chaining together the andThen methods we can greatly simplify the code and make it even more readable:

return doSomething()
  .andThen((r1) => r1.doSomething1())
  .andThen((r2) => r2.doSomething2())
  .andThen((r3) => r3.doSomething3())

This can easily be read as if all of the steps succeed then the value returned will be the final result of the r3.doSomething3(). If at any point there was a failure then that failure will be returned and the sequence stopped. So if the original call succeeded but the r1.doSomething1() failed then the return result will be the error code generated by r1.doSomething1(). There is an equivalent andThen method for asynchronous calls named andThenAsync. However since it returns a future the chaining concept can make for unwieldy wrapping of (await obj.method()) blocks. Instead something like what the not-as-concise first example can be used to capture the await value and then move on with non-asynchronous call chaining again:

final asyncResult = await doSomething()
  .andThen((r1) => r1.doSomething1())
  .andThen((r2) => r2.doSomething2())
  .andThenAsync((r3) async  => r3.doSomething3())
  
return asyncResult
  .andThen((r4) => r4.doSomething4())
  .andThen((r5) => r5.doSomething5())

Result Monads From Exceptions

Often you are dealing with code that is expected to throw exceptions in your method, unless they are also working with result monads especially. You therefore want to catch the error and propagate the errors of that in a failure monad. While one could easily wrap it in a try/catch block for a catch-all exception handling there is a runCatching method in the Result Monad library. Back to our file access library example, assuming the lower level library is throwing exceptions:


final sizeResult = runCatching((){
  final file = File(filename);
  final stat = file.stats();
  return stat.fileSize();
});

sizeResult.match(
  (size) => print('File size: $size'),
  (error) => print('Error accessing $filename: $error')
);

There is an equivalent of this method for asynchronous calls named runCatchingAsync.

In order to make syntax for asynchronous methods that return result monads more concise there is a type definition called FutureResult. It has the same signature as Result but wraps it a future:

//synchronous method signature
Result<File, FileError> open(String filename){...} 

//asynchronous method signature without the typedef
Future<Result<File, FileError>> openAsync(String filename) async {...}

//asynchronous method signature with the typedef
FutureResult<File, FileError> openAsync(String filename) async {...}

Result and Error Transformation

Sometimes we want to transform the type of the result or error while retaining the underlying state. For example perhaps we have a User object as the value type but we want the user’s name. Conversely perhaps our library call returns an API-specific error code and we want it to be in our own error types. The methods mapValue and mapError are used for both of these cases. Below we use both to transform the type of either of these, but they can be used individually too.

Result<String, OurErrorType> getUser() {
  final Result<User, ApiErrorType> userResult = userService.getUser(userId);
  return userResult
    .mapValue((user) => user.userName)
    .mapError((error) => error.code == 404 ? OurErrorType.notFound : OurErrorType.unknownError);

Conclusion

Here I’ve laid out both why I like result monads and documented how to use my implementation of it in Dart. You can see a more comprehensive working example in the examples folder of the repository, direct link here . The library source code and issue tracker is here with the pub.dev entry here .