Dart Result Monad 2.0 Updates

Last year as I really got into doing Dart and Flutter programming I decided I needed a library that got me the same behaviors I had with the Result Monad library I had in Kotlin. In November I published the 1.x version of it which I wrote about in this post . There were some aspects which I thought were a little clunky even then. As I have been using this on a bigger project now I found some other shortcomings as well. This was the impetus needed for making those improvements and pushing out a 2.0 version of the library. This post goes through those improvements. The library source code and issue tracker is here with the pub.dev entry here .

Allow Nullable Types

In the first implementation of the library I intentiontally steered away from allowing nullable types. One of the big points of using result monads is to get away from the practice of returning null when there is no value or a problem such as:

String? doSomething() {
    ...
    if (someError) {
        return null;
    }

    ...

    return 'Some string';
}

Unfortunately it turned out there were cases where I needed to return a nullable type in order to be compliant with an existing service I was interacting with. In that case, because of the way some other API was designed, a null value for a return was completely legitimate. It therefore didn’t make sense to return an error result monad. It was possible I could force the library user to return some stand in value, which is a practice if the library has no option for nullable types. That was adding unnecessary complexity just to get around a limitation that didn’t inherently need to be there. Therefore in the 2.0 version nullable types are supported for both success and error value types. If one doesn’t explicitly say the type is nullable though it will still not compile. So given a function that returns a nullable type:

String? someOtherFunction() {
  ...
}

If you try to return that result for a monad that doesn’t explicitly allow nullable types like this:

Result<String, String> someFunction() {
  String? value = someOtherFunction();
  return Result.ok(value);
}

It will throw a compiler error. However if you explicitly allow a nullable type then it will complie and run:

Result<String?, String> someFunction() {
  String? value = someOtherFunction();
  return Result.ok(value);
}

Concise Guaranteed “andThen” Methods

One of the powers of the result monad is the ability to make more expressive code by chaining together follow-on operations. It allows us to collapse code that reads like this:

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();

…down into code that reads like this:

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

This looks perfect! At least, it does when the functions are themselves returning a result monad. However if it is a function that does not then it was necessary to wrap the calls in a result monad here. That turned the above into:

return doSomething()
  .andThen((r1) => Result.ok(r1.doSomething1()))
  .andThen((r2) => Result.ok(r2.doSomething1()))
  .andThen((r3) => Result.ok(r3.doSomething1()))

This isn’t the end of the world but it is clunky. With the new “success” versions of the various andThen methods it now looks more like the original intended code:

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

What happens if one of those commands were intended to always work but doesn’t? The exceptions are caught and the result propagates like an error automatically. There is an asynchronous version of this as well to support asynchronous follow-on functions. So, for example this method:

Result<String,String> doSomething() {
    final arr = [1, 2, 3];
    return Result.ok('Success')
        .andThen((_) => Result.ok(arr[arr.length + 1]))
        .andThen((value) => Result.ok('$value'));
}

…will not throw an exception but instead will wrap the RangeError exception thrown by the first andThen call and pass that back as an error result monad.

Concise Future “andThen” Methods

The biggest stumbling syntax stumbling block in the 1.x version of the library is what would happen after an asynchronous call. Chaining together asynchronous calls was beyond clunky looking. You would need to do nested asynchronous calls that percolated out to make very unreadable code. Such as:

  final result = (await (await doSomething1()
              .andThenAsync((p) async => await doSomethingAsync1()))
          .andThenAsync((p) async => await doSomethingAsync2()))
      .andThen((p) => doSomething2());
  print(result);

That ended up causing us to lose the chaining benefit of the andThen calls causing a reset:

  final r1 = await doSomething1().andThenAsync((p) async => await doSomethingAsync1());
  final r2 = await r1.andThenAsync((p) async => await doSomethingAsync2());
  final result = r2.andThen((p) => doSomething2());
  print(result);

Yes we lost the chaining benefits but in the end this is about the expressiveness of the code and the second example is better than the first, especially if the function bodies are large. With 2.0 we added FutureResult extensions that allow us to have the chaining in a concise syntax, including with the success only handlers:

  final result = await doSomething1()
      .andThenAsync((p) async => await doSomethingAsync1())
      .andThenAsync((p) async => await doSomethingAsync2())
      .andThenAsync((p) async => doSomething2())
      .andThenSuccessAsync((p) async => p.length);

Notice that each of the subsequent calls is always the async version. Unfortunately we have to propagate the Future up to get proper asynchronous handling. For functions that don’t do anything asynchronous it simply requires the additional async keyword in the definition. Otherwise it has no other impact on the implementation. If one prefers they could continue do some of the call breaking thus they can go back to having normal andThen calls after the asynchronous operation. However this syntax is concise enough to make that optional.

Casting For Error Type Propagation

Although result chaining can be beneficial sometimes the syntax can start looking a bit convoluted. In those cases sometimes we do go back to something like the error handling propagation highlighted above:

final result = doSomething();
if(result.isFailure) {
    return result;
}

One thing I never liked about this was the need to sometimes explicitly re-wrap the error with more verbose syntax if the success types were different even though the error types were the same. There are already mapValue and mapError methods for the contingencies where either of those two are different than the needed return type. However if it is a case like the above in a function chain like this:

Result<int, String> doSomething1() {
    ...
}

Result<String,String> doSomething2() {
    final result = doSomething2();
    if(result.isFailure) {
        return result;
    }
}

…this code would fail to compile. You can see that the types are actually different. We can also see here that we, the develop, knows that the success type doesn’t matter because it doesn’t have a succes result it has a failure one. The compiler will understandably not be so forgiving. In the 1.x version the solution was to re-wrap the error:

Result<String,String> doSomething2() {
    final result = doSomething2();
    if(result.isFailure) {
        return Result.error(result.error);
    }
}

This always felt clunky though. In the 2.0 version we now have a errorCast method that automatically handles the transformation:

Result<String,String> doSomething2() {
    final result = doSomething2();
    if(result.isFailure) {
        return result.errorCast();
    }
}

This is doing essentially the same as our code but collapsing the syntax for us. If this is called on a success result monad though it will throw an exception just as any access to the error result would in that scenario.

Conclusion

With this latest 2.0 version of the Dart Result Monad library a lot of the initial limitations of the original library have been solved. I’ve been using the new syntax extensively in my own development for about a week now and it has been a huge improvement over the original, especially with the asynchronous call improvements.