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. ↩
Top comments (0)