DEV Community

Ryan Lee
Ryan Lee

Posted on • Updated on • Originally published at rlee.dev

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

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
Enter fullscreen mode Exit fullscreen mode

And we can convert it into a curried function like so:

const writeC = (key: string) => (value: string) => (flush: boolean) =>
  write(key, value, flush)
Enter fullscreen mode Exit fullscreen mode

Trivially we can call the function like this:

writeC('key')('value')(true)
Enter fullscreen mode Exit fullscreen mode

And, if we wanted to do the same with our pipe syntax we could try something like this.

// ❌ Wrong
pipe(true, 'value', 'key', writeC)
Enter fullscreen mode Exit fullscreen mode

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)))
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)))
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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[]>
Enter fullscreen mode Exit fullscreen mode

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[]>
Enter fullscreen mode Exit fullscreen mode

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'))
Enter fullscreen mode Exit fullscreen mode

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),
)
Enter fullscreen mode Exit fullscreen mode

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),
)
Enter fullscreen mode Exit fullscreen mode

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)))
Enter fullscreen mode Exit fullscreen mode

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. 

Oldest comments (0)