Future & Task: asynchronous Functional Programming

27 October 2022

β€’

10 min read

β€’ Functional programming

Dart supports asynchronous programming using Future. The language supports both async/await as well as a full API inside the Future class.

While Future works great in dart, the Future class itself is not functional programming friendly. fpdart brings another solution to work with async requests called Task.

If you are new to fpdart and functional programming, or if you are new to the whole idea of asynchronous programming with Future and async/await, all these solution will probably cause you some confusion.

In this post, I will give an overview of how Future works, what is Task, and why fpdart and functional programming prefers to use Task.


Future in dart for async code

When we talk about an asynchronous function, we mean an operation that takes a considerable amount of time.

These functions are asynchronous: they return after setting up a possibly time-consuming operation (such as I/O), without waiting for that operation to complete (from the dart documentation)

Why do we make a distinction?

The reason is simple: we don't want to freeze the app while waiting for the result of an expensive operation.

Without Future and async, every time we make an http request, the UI will stop working because the code is waiting for the response to come. This means that all animations will stop, every click will be unresponsive, everything will be freezed.

Instead of blocking all computation until the result is available, the asynchronous computation immediately returns a Future which will eventually "complete" with the result. (from the Future API)

This is related to the concept of event loop in dart: "A good thing to know about futures is that they’re really just an API built to make using event loop easier." (read here for more about this)

The Future API in dart

Future in dart integrates an extensive API to handle async requests. These API includes methods like then, catchError, whenComplete.

These methods are all designed to handle the result of the operation: either an error (catchError) or a valid response (then).

Future result = costlyQuery(url); // <- Future πŸ”œ
result
    .then((value) => expensiveWork(value))
    .then((_) => lengthyComputation())
    .then((_) => print('Done!'))
    .catchError((exception) {
        /// Handle exception... 🧐
    });

async/await in dart

dart also provides async/await to more easily work with asynchronous code.

Instead of chaining a series of then, we declare the function as async and we use await to wait until the async operation is completed. All of these without blocking the UI.

While execution of the awaiting function is delayed, the program is not blocked, and can continue doing other things. (source)

async/await works the same as using then and catchError. Using async/await is just more convenient and easier to read (it is also recommended by the dart team).

Furthermore, by using async/await you can handle errors using try/catch.

The above example using async/await becomes:

Future operation() async {
  try {
    final value = await costlyQuery(url); // <- Future πŸ”œ
    await expensiveWork(value);
    await lengthyComputation();
    print('Done!');
  } catch (exception) {
    /// Handle exception... 🧐
  }
}

Task in functional programming

As mentioned, fpdart exports a new class called Task.

Task is nothing more than a wrapper around Future:

task.dart
class Task<A> {
  final Future<A> Function() _run;

  /// Build a [Task] from a function returning a [Future].
  const Task(this._run);

  /// Run the task and return a [Future].
  Future<A> run() => _run();

At its core Task is just Future<A> Function(), a function that returns a Future.

This is called lazy evaluation: the code does not execute the Future until you call the run() method.

What's the point of Task?

The main reason why Task exists is to have full control over when the async operation happens:

Task don't only give us control over what happens with the result of its operation (Future), but also over if and when the operation will happen.

Think about it: when you call a Future you are not in control of its execution, the async operation will start right away.

On the other hand, Task will not do anything until run() is called. Nothing. In fact, Task without the run() method will be utterly meaningless.

Let's look at the simplest example possible:

Task<int> getTask() => Task(() async {
      print("I am running [Task]...");
      return 10;
    });

Future<int> getFuture() async {
  print("I am running [Future]...");
  return 10;
}

void main() {
  Task<int> taskInt = getTask();
  Future<int> futureInt = getFuture();
}

What will happen when we run the following code? Here is the result πŸ‘‡:

I am running [Future]...
Exited

As you can see, Future started right away. You have no way of controlling its execution once you call getFuture().

Task is different. Task is lazy: it won't do anything until you call run():

void main() {
  Task<int> taskInt = getTask();
  Future<int> futureInt = getFuture();

  Future<int> taskRun = taskInt.run();
}
I am running [Future]...
I am running [Task]...
Exited

In fact, notice how we can refactor the code as follows:

Future<int> getFuture() async {
  print("I am running [Future]...");
  return 10;
}

Task<int> getTask() => Task(getFuture);

Indeed Task is only a container for a Future πŸ’πŸΌβ€β™‚οΈ.

Advantages of Task

Task enables us to have better reasoning about exactly when our asynchronous operations are performed.

Furthermore, Task also has a more extensive API to manipulate the response and recover from errors compared to Future.

On top of that, fpdart also has TaskOption and TaskEither, specifically designed for error handling.

Apart from these perks, Task is no different than Future πŸ’πŸΌβ€β™‚οΈ.


Example of Future and Task

Let's imagine some relatively simple requirements for an example of using Future and Task:

Get the username and email of the user (2 separate API endpoints), then append a prefix to both, and finally send the result to a third API endpoint.

If you encounter an error while reading the username, then fallback to request an encoded name (int) and execute a function to decode the name.

These requirements have some particular needs:

  • Recover from errors (fallback to name instead of username)
  • Apply a function to the result of an async request (prefix)

Both getting the user information and sending the final request are async operations. Our API looks like the following (leaving out the details of each request for the sake of the example):

/// Helper functions βš™οΈ (sync)
String addNamePrefix(String name) => "Mr. $name";
String addEmailPrefix(String email) => "mailto:$email";
String decodeName(int code) => "$code";

/// API functions πŸ”Œ (async)
Future<String> getUsername() => ...
Future<int> getEncodedName() => ...

Future<String> getEmail() => ...

Future<bool> sendInformation(String usernameOrName, String email) => ...

Now we want to implement the logic defined by the requirements.

Solution using Future

If we use Future and async/await, the final result will look something like this:

Future<bool> withFuture() async {
  late String usernameOrName;
  late String email;

  try {
    usernameOrName = await getUsername();
  } catch (e) {
    try {
      usernameOrName = decodeName(await getEncodedName());
    } catch (e) {
      throw Exception("Missing both username and name");
    }
  }

  try {
    email = await getEmail();
  } catch (e) {
    throw Exception("Missing email");
  }

  try {
    final usernameOrNamePrefix = addNamePrefix(usernameOrName);
    final emailPrefix = addEmailPrefix(email);
    return await sendInformation(usernameOrNamePrefix, emailPrefix);
  } catch (e) {
    throw Exception("Error when sending information");
  }
}

We can notice some patterns in this code:

  • No clear error response: what happens if an error occurs? What should the function return? How do we know what could go wrong when we call this function?
  • Multiple try/catch blocks: since we need to use try/catch to spot errors, in order to define a specific error for each situation we need to define multiple try/catch
  • Nested try/catch blocks: we want to know if getting the username fails, and in such case read the fallback name. In order to know if getting username failed, we need to nest 2 try/catch blocks
  • Using late: Since we need the value of both usernameOrName and email outside their respective try/catch block, we need to use the late keyword to inform the type system that eventually we will initialize those values

An alternative to avoid nesting try/catch could be the following:

try {
  usernameOrName = await getUsername().catchError(
    (dynamic _) async => decodeName(await getEncodedName()),
  );
} catch (e) {
  throw Exception("Missing both username and name");
}

There are different ways of implementing this function using the Future API and async/await.

Solution using Task

This below is instead the solution using Task (specifically TaskEither):

TaskEither<String, bool> withTask() => TaskEither.tryCatch(
      getUsername,
      (_, __) => "Missing username",
    )
        .alt(
          () => TaskEither.tryCatch(
            getEncodedName,
            (_, __) => "Missing name",
          ).map(
            decodeName,
          ),
        )
        .map(
          addNamePrefix,
        )
        .flatMap(
          (usernameOrNamePrefix) => TaskEither.tryCatch(
            getEmail,
            (_, __) => "Missing email",
          )
              .map(
                addEmailPrefix,
              )
              .flatMap(
                (emailPrefix) => TaskEither.tryCatch(
                  () => sendInformation(usernameOrNamePrefix, emailPrefix),
                  (_, __) => "Error when sending information",
                ),
              ),
        );

Here a list of some noticeable differences:

  • The error is encoded in the return type: TaskEither<String, bool> tells us that we either get an error of type String or a valid response of type bool
  • Recovering from an error in getting username is easier: TaskEither provides an alt ("alternative") method to do just that
  • Declarative way to add prefix by using the map method
  • No need of late or intermediate variables
  • No need of using try/catch nor async/await

These advantages are made possible by the API provided by TaskEither.

Furthermore, notice how calling withTask() doesn't do anything. If you want to actually perform the request you need to call also the run() method (which returns a Future πŸ’πŸΌβ€β™‚οΈ):

withTask().run();

Comparison between Future and Task

We can point out some important differences between the 2 solutions:

  • Future looks more familiar and linear dart code compared with Task
  • The solution with Future looks shorter at first glance
  • Understanding the solution using Future is not immediate. Task does arguably a better job (that is, if you are used to functional programming πŸ˜…)
  • Task does better at error handling
  • Task is safer: it does not throw Exception nor it uses any intermediate variables or late keyword

Of course this is just a simple example. Many of the points above can be argued, and a lot depends on personal preferences and how you are used to write code.

Nonetheless, it's clear that Task gives us more control and a richer API to handle more complex usecases.


You can find more about Task, fpdart, and functional programming in other articles in my blog. I am also going to write more about these topics.

My goal is to make functional programming more accessible for everyone, showing how powerful it can be, and bringing more people to the functional world πŸš€


Read more about Task and Future (Promise):