loading...

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

ryanleecode profile image Ryan Lee Originally published at rlee.dev ・6 min read

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:

  1. We have to awkwardly name our variables with a number suffix because we don't want to shadow the outer variable.
  2. It doesn't scale if we have more parameters.
  3. 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!.


  1. Sequences and traversals use ap internally. 

Discussion

pic
Editor guide