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
:
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 therun()
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
andIf you encounter an error while reading the username, then fallback to request an encoded
name
(int
) and execute a function to decode thename
.
These requirements have some particular needs:
- Recover from errors (fallback to
name
instead ofusername
) - 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 gettingusername
failed, we need to nest 2 try/catch blocks - Using
late
: Since we need the value of bothusernameOrName
andemail
outside their respective try/catch block, we need to use thelate
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 typeString
or a valid response of typebool
- Recovering from an error in getting
username
is easier:TaskEither
provides analt
("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 withTask
- 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 handlingTask
is safer: it does not throwException
nor it uses any intermediate variables orlate
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
):