Intro to fp-ts, part 4: Tasks as an alternative to Promises
Previous articles:
In the previous article, we looked at the Option and Either types, which provide functional replacement for nullable types and throwing exceptions. In this article, I want to talk about lazy functional replacement for promises — tasks. They will allow us to get closer to the concept of algebraic effects, which I will discuss in detail in the following articles.
As always, I will illustrate examples using data structures from the fp-ts library.
Promise/A+ which we have lost deserved
Back in 2013, Brian McKenna wrote a post on what should be changed in the Promise/A+ spec to make promises match the monadic interface. These changes were minor, but very important from the point of view of observance of the category-theoretic laws for the monad and functor. So Brian McKenna suggested:
- To add a static method for creating a promise —
Promise.point
:Promise.point = function(a) { // ... };
- To add a method
onRejected
for handling case of failure:Promise.prototype.onRejected = function(callback) { // ... };
- Make
Promise.prototype.then
accept only one callback, and force it to return a new promise:Promise.prototype.then = function(onFulfilled) { // ... };
- Finally, make promises lazy by adding
done
method:Promise.prototype.done = function() { // ... };
These changes would allow for a simple extensible API that would later allow for elegantly decoupling the behavior of the computation context from the immediate business logic — say, as it is done in Haskell with its do notation, or in Scala with for comprehension. Unfortunately, the so-called “pragmatists” like Domenic Denicola and several other contributors rejected this proposal, so the promises in JS remained an eager bastard, which is quite problematic to use in idiomatic FP code that implies equational reasoning and adhere to the principle of referential transparency. However, thanks to a simple trick, you can make a law-abiding abstraction out of promises, for which you can implement instances of a functor, applicative, monad, and much more.
Task<A>
— a lazy promise
The first abstraction to make a promise law-abiding is Task. Task<A>
is an asynchronous computation primitive that impersonates a task that always succeeds with a value of type A
(that is, it does not contain expressive means to represent an error state):
type Task<A> = () => Promise<A>;
// Unique resource identifier — a type tag:
const URI = 'Task';
type URI = typeof URI;
// Definition of a Task as a higher-kinded type:
declare module 'fp-ts/HKT' {
interface URItoKind<A> {
[URI]: Task<A>;
}
}
For Task, you can define instances of classes of types Functor, Apply, Applicative, Monad. Notice how one of the simplest typeclasses, Functor, produces structures that have gradually more complex behavior.
N.B. I will also make a note here that for ease of implementation, the code for handling the rejected state in the promises used inside the Task is not written — it is assumed that the construction of Task instances occurs using constructor functions (
of
), and not ad hoc.
Recall: Functor allows you to convert the value returned by the task from type A
to type B
using a pure function:
const Functor: Functor1<URI> = {
URI,
map: <A, B>(taskA: Task<A>, transform: (a: A) => B): Task<B> => async () => {
const prevResult = await taskA();
return transform(prevResult);
},
};
Apply allows you to apply some transformation function, obtained asynchronously, to the data that will be returned by the task. For Task, you can write two instances of Apply — one will calculate the result and the transform function sequentially, the other — in parallel:
const ApplySeq: Apply1<URI> = {
...Functor,
ap: <A, B>(taskA2B: Task<(a: A) => B>, taskA: Task<A>): Task<B> => async () => {
const transformer = await taskA2B();
const prevResult = await taskA();
return transformer(prevResult);
},
};
const ApplyPar: Apply1<URI> = {
...Functor,
ap: <A, B>(taskA2B: Task<(a: A) => B>, taskA: Task<A>): Task<B> => async () => {
const [transformer, prevResult] = await Promise.all([taskA2B(), taskA()]);
return transformer(prevResult);
},
};
Applicative functor (or just Applicative for short) allows constructing new values of a type F
, “lifting” them into the computational context F. In our case, the applicative wraps a pure value into a task. For simplicity, I’ll use a sequential Apply instance for inheritance:
const Applicative: Applicative1<URI> = {
...ApplySeq,
of: <A>(a: A): Task<A> => async () => a,
};
Monad allows you to organize sequential computations — first, the result of the previous task is calculated, and then the result is used for subsequent calculations. Note that although we can use any instance of an applicative to define a monad — using sequential or parallel Apply — the chain
function, which is the heart of the monad, is evaluated strictly sequentially for the Task. This directly follows from the types, and, in general, is not something difficult — but I consider it to be my duty to pay attention to this:
const Monad: Monad1<URI> = {
...Applicative,
chain: <A, B>(taskA: Task<A>, next: (a: A) => Task<B>): Task<B> => async () => {
const prevResult = await taskA();
const nextTask = next(prevResult);
return nextTask();
},
};
Having such expressive abilities as a monad and a functor in hand, one can already write simple programs in an imperative style: do branching, calculate something recursively. But to work on tasks in the real world, you need to be able to express an erroneous state, and the next abstraction will help with this — TaskEither.
TaskEither<E, A>
— a task which may fail
In the previous article we have looked at the Either data type, which represents computations that can follow one of two paths. For the Either type, you can implement instances of a functor, a monad, an alternative (Alt + Alternative, allows you to express fallback values), a bifunctor (allows you to modify both the left and right sides of Either at the same time) and much more.
By combining Task and Either, we get an abstraction that has new semantics — TaskEither<E, A>
is an asynchronous computation that can succeed with a value of type A
or fail with an error of type E
. In fp-ts
you can find a number of combinators for TaskEither, such as:
-
bracket: <E, A, B>( acquire: TaskEither<E, A>, use: (a: A) => TaskEither<E, B>, release: (a: A, e: E.Either<E, B>) => TaskEither<E, void> ) => TaskEither<E, B>
bracket
allows you to safely acquire, use, and release a resource — for example, a database connection or a file descriptor. In this case, therelease
function will be called regardless of whether theuse
function succeeds or fails. -
tryCatch: <E, A>( f: Lazy<Promise<A>>, onRejected: (reason: unknown) => E ) => TaskEither<E, A>
tryCatch
wraps a promise that can be rejected into a promise that can never be rejected and that returns an Either. This function, together with the nexttaskify
function, is one of the cornerstones for adapting a third-party library functions to a functional style. There is also a functiontryCatchK: <E, A extends readonly unknown [], B>(f: (...a: A) => Promise<B>, onRejected: (reason: unknown) => E) => (...a: A) => TaskEither<E, B>
, which can work with functions of several arguments. -
taskify<A, L, R>( f: (a: A, cb: (e: L | null | undefined, r?: R) => void ) => void): (a: A) => TaskEither<L, R>
taskify
is a function that allows you to turn a Node.js-style callback into a function that returns a TaskEither.taskify
is overloaded to wrap functions with 0 to 6 arguments + callback.
By haing Traversable and Foldable instances already implemented for TaskEither, it is easy to work with an array of tasks. Functions traverseArray
, traverseArrayWithIndex
, sequenceArray
and their sequential variations traverseSeqArray
, traverseSeqArrayWithIndex
, sequenceSeqArray
allow you to tarverse an array of tasks and get as a result a task whose result is an array of results. For example, here’s how to write a program that reads three files from disk and writes their contents into a single new file:
import * as fs from 'fs';
import { pipe } from 'fp-ts/function';
import * as Console from 'fp-ts/Console';
import * as TE from 'fp-ts/TaskEither';
// First I'll wrap functions from `fs` module using `taskify` to make them pure:
const readFile = TE.taskify(fs.readFile);
const writeFile = TE.taskify(fs.writeFile);
const program = pipe(
// Entry point — an array of tasks which read three files from the disk:
[readFile('/tmp/file1'), readFile('/tmp/file2'), readFile('/tmp/file3')],
// For this particular example the order of array traversion is important, so I'll use sequential version
// of traverseArray function — `traverseSeqArray`:
TE.traverseSeqArray(TE.map(buffer => buffer.toString('utf8'))),
// I'm using `chain` here from the Monad interface to do a sequence of computations:
TE.chain(fileContents => writeFile('/tmp/combined-file', fileContents.join('\n\n'))),
// Finally, I want to know whether my program finished successfully or failed, and log this.
// Here the module `fp-ts/Console` will help me, which contains pure functions for working with the console:
TE.match(
err => TE.fromIO(Console.error(`An error happened: ${err.message}`)),
() => TE.fromIO(Console.log('Successfully written to the combined file')),
)
);
// In the end, I run my pure program, executing all side effects:
await program();
N.B.: If you noticed, I write about functions that return TaskEither, as about pure ones. In my previous articles, I touched on this topic already: in the functional approach, a lot of work is built upon creating a description of computations and then interpreting them as needed. When I talk about funcitonal effects and free monads, this topic will be covered in more details; for now I’ll just say that Task/TaskEither/ReaderTaskEither/etc. are just values, not running computations, so they can be handled more freely than promises. Code written with TaskEither is easier to refactor using the principle of referential transparency: tasks can be easily created, canceled, and passed to other functions.
It would seem that TaskEither gives good expressiveness already — you can see in the types what result and what error a function can return. But we can go a little bit further and add another layer of abstraction — Reader.
Reader
— accessing an immutable computational context
If we take the type of the function A -> B
, and fix the type of the argument A
as unchanged, we get a structure for which we can define instances of a functor, applicative, monad, profunctor, category, etc., which was named Reader
:
// Reader is a function from some environment of type `R` into a value of type `A`:
type Reader<R, A> = (env: R) => A;
// Reader is a higher-kinded type, so we need to define everything for that:
const URI = 'Reader';
type URI = typeof URI;
declare module 'fp-ts/HKT' {
interface URItoKind2<E, A> {
readonly Reader: Reader<R, A>;
}
}
For Reader we can define instances of the following type classes:
// Function:
const readerFunctor: Functor2<URI> = {
URI,
map: <R, A, B>(fa: Reader<R, A>, f: (a: A) => B): Reader<R, B> => (env) => f(fa(env))
};
// Apply:
const readerApply: Apply2<URI> = {
...readerFunctor,
ap: <R, A, B>(fab: Reader<R, (a: A) => B>, fa: Reader<R, A>): Reader<R, B> => (env) => {
const fn = fab(env);
const a = fa(env);
return fn(a);
}
};
// Applicative:
const readerApplicative: Applicative2<URI> = {
...readerApply,
of: <R, A>(a: A): Reader<R, A> => (_) => a
};
// Monad:
const readerMonad: Monad2<URI> = {
...readerApplicative,
chain: <R, A, B>(fa: Reader<R, A>, afb: (a: A) => Reader<R, B>): Reader<R, B> => (env) => {
const a = fa(env);
const fb = afb(a);
return fb(env);
},
};
Reader allows you to implement an interesting pattern — access to some immutable environment. Supose we want the application to have access to a configuration with the following type:
interface AppConfig {
readonly host: string; // web-server host name
readonly port: number; // a port our webserver will be listening
readonly connectionString: string; // a DB connection parameters
}
For simplicity, I’ll make the DB and express types aliases for string literals — right now it’s not so important to me what business type the functions will return; it is more important to demonstrate the principles of working with Reader:
type Database = 'connected to the db';
type Express = 'express is listening';
// Our application is a *value of type A*, computed in *a context of accessing configuration of type AppConfig*:
type App<A> = Reader<AppConfig, A>;
First, let’s write a function which connects to our fake Express:
const expressServer: App<Express> = pipe(
// `ask` allows "asking" from the environemt a value of type AppConfig. Its implementation is trivial:
// const ask = <R>(): Reader<R, R> => r => r;
R.ask<AppConfig>(),
// I use functor to get access to the config and do something using it — say, log it
// and return a value of type Express:
R.map(
config => {
console.log(`${config.host}:${config.port}`);
// In a real application here we should do asynchronous operations for actually running the server.
// We'll talk about working with asynchrony in the next section:
return 'express is listening';
},
),
);
Our databaseConnection
function works in a context of config and return a fake database connection:
const databaseConnection: App<Database> = pipe(
// `asks` allows asking for an environment and change it to some other type — for example, here I just get a string
// with database connection from the config:
R.asks<AppConfig, string>(cfg => cfg.connectionString),
R.map(
connectionString => {
console.log(connectionString);
return 'connected to the db';
},
),
);
Finally, our application will not return anything, but it will still work in the context of a config.
Here I will use the sequenceS
function from the fp-ts/Apply
module to transform the structure like
interface AdHocStruct {
readonly db: App<Database>;
readonly express: App<Express>;
}
into type App<{ readonly db: Database; readonly express: Express }>
. We pretend to “get” the data wrapped in the App context from the structure, and construct a new App context with a similar structure, only containing the already cleaned data:
import { sequenceS } from 'fp-ts/Apply';
const seq = sequenceS(R.Apply);
const application: App<void> = pipe(
seq({
db: databaseConnection,
express: expressServer
}),
R.map(
({ db, express }) => {
console.log([db, express].join('; '));
console.log('app was initialized');
return;
},
),
);
In order to “run” the Reader<R, A>
, it should be passed an argument of type fixed in type parameter R
, and the result will be of type A
:
application({
host: 'localhost',
port: 8080,
connectionString: 'mongo://localhost:271017',
});
ReaderTaskEither<R, E, A>
— a task which runs in the context of some environment
By combining Reader and TaskEither, we get the following abstraction: ReaderTaskEither<R, E, A>
is an asynchronous computation that has access to some immutable environment of type R
, can return a result of type A
or fail with an error of type E
. It turned out that such a construction allows one to describe the vast majority of tasks that a programmer has to face every day when writing functions. Moreover, by filling the ReaderTaskEither
type parameters with the types any
and never
, you can get the following abstractions:
// Task may never fail and can be executed in any environment:
type Task<A> = ReaderTaskEither<any, never, A>;
// ReaderTask never fails, but requires an environment of type `R`:
type ReaderTask<R, A> = ReaderTaskEither<R, never, A>;
// TaskError may fail with a generic error of type `Error` (or its subtypes):
type TaskError<A> = ReaderTaskEither<any, Error, A>;
// ReaderTaskError may fail with an error of type `Error` and requires an environment of type `R`:
type ReaderTaskError<R, A> = ReaderTaskEither<R, Error, A>;
// TaskEither, which we already have met before, could be thought of as an alias for ReaderTaskEither,
// which could be executed an any environment:
type TaskEither<E, A> = ReaderTaskEither<any, E, A>;
For ReaderTaskEither, the corresponding fp-ts
module has a large number of constructors, destructors and combinators.
This concludes this article. The ReaderTaskEither abstraction brings us smoothly to the concept of functional effects. But before taking a look at them on the example of any ZIO-like library, I want to talk about free constructions using the example of free monads (Free monads).
You can find code examples from this article in my Gist.