Practical Guide to Fp‑ts P5: Apply, Sequences, and Traversals

Table of Contents 🔗
Introduction 🔗
Welcome to part 5 of this series on learning fp-ts the practical way.
By now you've been introduced to the operators
of
,
map
,
chain
,
flatten
, but there's one operator we haven talked about yet:
ap
or apply. The
ap
operator is a greater part of what is called an Applicative. And applicatives form the basis for sequences and traversals.
In this post, I will explain the rationale for
ap
, its usecases, and how we don't actually need it because we have sequences and traversals.
Apply 🔗
What is the mysterious
ap
operator, otherwise known as Apply?
In many ways, it is like the reverse of
map
. Rather than piping a value into a function, you pipe a function into a value.
To demonstrate this, lets learn about currying. Currying is taking a function with multiple parameters and converting it into a higher order function such that it takes a single argument repeatedly.
For example, we can have a
write
function that takes 3 parameters.
declare function write(key: string, value: string, flush: boolean): unknown
And we can convert it into a curried function like so:
const writeC = (key: string) => (value: string) => (flush: boolean) =>
write(key, value, flush)
Trivially we can call the function like this:
writeC('key')('value')(true)
And, if we wanted to do the same with our pipe syntax we could try something like this.
// ❌ Wrong
pipe(true, 'value', 'key', writeC)
But unfortunately this doesn't work because pipeline is evaluated from left-to-right; the compiler will complain that
true
cannot be piped into
value
and
value
cannot be piped into
key
. To make this work, we will need to enforce the order of operations (just like in math), with more pipes!
// ✅ Correct
pipe(true, pipe('value', pipe('key', writeC)))
Now the compiler understands because we force the right side to evaluate first. However, this syntax isn't ideal because its annoying to add additional pipes for the sake of ordering.
The solution to this is
ap
.
import { ap } from 'fp-ts/lib/Identity'
pipe(writeC, ap('key'), ap('value'), ap(true))
Remember when I said
ap
is just piping a function into a value? This is exactly what you see here.
writeC
is piped into
key
which forms the function
(value: string) => (flush: boolean) => write(key, value, flush)
. This function is piped into
value
which forms the function
(flush: boolean) => write(key, value, flush)
. And finally, this last function is piped into
true
which calls our 3 parameter write function:
write(key, value, flush)
.
In essence,
ap
just makes it easier to curry function values while keeping the correct order of operations.
Another use case for
ap
is when you have functions and values that don't play well together because one of them is trapped inside an
Option
or an
Either
, etc...
ap
is useful in this scenario because it can lift values or functions into a particular category.
To demonstrate, lets look at an example.
import * as O from 'fp-ts/lib/Option'
import { Option } from 'fp-ts/lib/Option'
declare const a: Option<number>
declare const b: Option<string>
declare function foo(a: number, b: string): boolean
As you can see, we want to call
foo
using our variables
a
and
b
, but the problem is:
a
and
b
are in the Option category while
foo
takes plain values.
A naive way of executing
foo
is to use
chain
and
map
.
// Option<boolean>
O.option.chain(a, (a1) => O.option.map(b, (b1) => foo(a1, b1)))
But this is terrible because:
- We have to awkwardly name our variables with a number suffix because we don't want to shadow the outer variable.
- It doesn't scale if we have more parameters.
- Its ugly and confusing.
Lets try again.
First we need to convert
foo
into a curried function
fooC
.
const fooC = (a: number) => (b: string) => foo(a, b)
Then it is just the same thing as we did before, BUT we need to lift
fooC
into the
Option
category using
of
, because the
Option
version of
ap
must operate on two options.
// Option<boolean>
pipe(O.of(fooC), O.ap(a), O.ap(b))
Lets extend the example a bit further. Let say we had another function
bar
that takes a boolean (the return value of
foo
) and returns an
object
. Naturally, we want to call
foo
and subsequently
bar
with the return value of
foo
.
We have already computed
foo
as an
Option<boolean>
, so this is nothing more than a simple lift into
ap
declare function bar(a: boolean): object
const fooOption = pipe(O.of(fooC), O.ap(a), O.ap(b))
// Option<object>
pipe(O.of(bar), O.ap(fooOption))
Cool,
ap
is clearly powerful. But what are the problems with
ap
?
First, its boring to have to curried every function in existence just to use fp.
Second, reversing the order of the input value of a function inside of a pipe from left-to-right to right-to-left breaks the natural
flow
of operations.
In the real world, there's hardly a usecase for
ap
because we can leverage sequences instead.1
Sequences 🔗
So what is a sequence?
In math, we think of a sequence as a sequence of numbers. Similarly, we can apply this to a sequence of Options, a sequence of Eithers, etc...
The most common usecase for a sequence is convert an array of say Options into an Option of an array.
// How?
Array<Option<A>> => Option<A[]>
To do this, you need to provide sequence an instance of
Applicative
. An applicative has 3 methods:
of
,
map
, and
ap
. This applicative defines the type of the objects inside of the collection. For a list of
Options
, we would provide it with
O.option
.
import * as A from 'fp-ts/lib/Array'
import * as O from 'fp-ts/lib/Option'
const arr = [1, 2, 3].map(O.of)
A.array.sequence(O.option)(arr) // Option<number[]>
Now we lets go back to the problem: how do we use sequence such that we don't have to write a curried function and use
ap
?
Enter
sequenceT
.
SequenceT 🔗
sequenceT
is the same as a regular
sequence
except you pass it a rest parameter (vararg). The return value is the provided applicative with a tuple as the type parameter.
For example:
// Option<[number, string]>
sequenceT(O.option)(O.of(123), O.of('asdf'))
Now you see where this is going. We can just pipe this into our original
foo
and
bar
functions.
declare function foo(a: number, b: string): boolean
declare function bar(a: boolean): object
// Option<object>
pipe(
sequenceT(O.option)(O.of(123), O.of('asdf')),
O.map((args) => foo(...args)),
O.map(bar),
)
Note, I had to use the
...
spread syntax to convert the tuple into parameter form.
SequenceS 🔗
Sometime our function takes a single object parameter rather than multiple arguments. To solve this problem we can leverage
sequenceS
.
import * as E from 'fp-ts/lib/Either'
type RegisterInput = {
email: string
password: string
}
declare function validateEmail(email: string): E.Either<Error, string>
declare function validatePassword(password: string): E.Either<Error, string>
declare function register(input: RegisterInput): unknown
declare const input: RegisterInput
pipe(
input,
({ email, password }) =>
sequenceS(E.either)({
email: validateEmail(email),
password: validatePassword(password),
}),
E.map(register),
)
Traversals 🔗
Sometimes your inputs will not line up nicely and you need to perform some additional computations before applying
sequence
. Traversal is the answer to this. It performs the same thing sequence but lets us transform the intermediate value.
A good example network request to retrieve parts of a file. You either want all the parts or you want none of them.
import * as TE from 'fp-ts/lib/TaskEither'
import { TaskEither } from 'fp-ts/lib/TaskEither'
import * as A from 'fp-ts/lib/Array'
declare const getPartIds: () => TaskEither<Error, string[]>
declare const getPart: (partId: string) => TaskEither<Error, Blob>
// ✅ TE.TaskEither<Error, Blob[]>
pipe(getPartIds(), TE.chain(A.traverse(TE.taskEither)(getPart)))
Conclusion 🔗
In this post we've learned about Apply, its usecases, and how we can apply it to our lives with sequences and traversals.
Thanks for reading and if you like this content, please give me a follow on Twitter and shoot me a DM if you have questions!.
- Sequences and traversals use
ap
internally.↩