DEV Community

Ryan Lee
Ryan Lee

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

Practical Guide to Fp-ts P2: Option, Map, Flatten, Chain

Introduction

This is the second post in my series on learning fp-ts the practical way. In my first post, I introduced the building blocks of fp-ts: pipe and flow. In this post, I will introduce the Option type.

Options

Options are containers that wrap values that could be either undefined or null. If the value exists, we say the Option is of the Some type. If the value is undefined or null, we say it has the None type.

In fp-ts, the Option type is a discriminated union of None and Some.

type Option<A> = None | Some<A>
Enter fullscreen mode Exit fullscreen mode

Why should we use Option types in the first place? Typescript already has good ways to deal with undefined or null values. For example, we can use optional chaining or nullish coalescing.

Option types are useful because it gives us superpowers. The first superpower is the map operator.

Map

The map operator allows you to transform or intuitively map one value to another. Here is an example of a map function using the pipe operator and an anonymous function.

const foo = {
  bar:'hello',
}

pipe(foo, (f) => f.bar) // hello
Enter fullscreen mode Exit fullscreen mode

In this example, foo is mapped to foo.bar and we get the result 'hello'. Let's extend this further to handle the case where foo is possibly undefined using optional chaining.

interface Foo {
  bar: string
}

const foo = {
  bar: 'hello',
} as Foo | undefined

pipe(foo, (f) => f?.bar) // hello
Enter fullscreen mode Exit fullscreen mode

As expected, we get 'hello' again. But we can do better here. We have a named variable f in our anonymous function. In general, we want to avoid this. This is because it puts us at risk of shadowing an outer variable. Another reason is the difficulty of naming the variable. It is named f, but you could name it nullableFoo. Bottom line is, there is no good name for this variable.

Let's use object destructuring to solve this problem.

pipe(foo, ({ bar }) => bar) // Property 'bar' does not exist on type 'Foo | undefined'.ts (2339)
Enter fullscreen mode Exit fullscreen mode

Property 'bar' does not exist on type 'Foo | undefined'.ts (2339)

Oops. The compiler can't destructure an object that is possibly undefined.

Enter the Option type.

pipe(foo, O.fromNullable, O.map(({ bar }) => bar)) // { _tag: 'Some', value: 'hello' }
pipe(undefined, O.fromNullable, O.map(({ bar }) => bar)) // { _tag: 'None' }
Enter fullscreen mode Exit fullscreen mode

After replacing the anonymous function with a map function from the Option module, the compiler no longer complains. Why is this?

Let's start by looking at the output of both pipes. In the first pipe, we have { _tag: 'Some', value: 'hello' }. This is in comparison to the original output, 'hello'. Likewise, the second pipe does not output undefined but instead, outputs { _tag: 'None' }.

Intuitively, this must imply our map function is not operating over the raw values: 'hello' or undefined but rather, over a container object.

The second operation in our pipe function, O.fromNullable creates this container object. It lifts the nullable value into the container by adding a _tag property to discriminate whether the Option is Some or None. The value is dropped if the _tag is None.

Going back to our map function. How does O.map work over the Option container? It works by performing a comparison over the _tag property. If the _tag is Some, it transforms the value using the function passed into map. In this case, we transformed it using ({ bar }) => bar. However, if the _tag is None, no operation is performed. The container remains in the None state.

Flatten

How would we handle a situation where the object has sequentially nested nullable properties? Let's extend the example we had above.

interface Fizz {
  buzz: string
}

interface Foo {
  bar?: Fizz
}

const foo = { bar: undefined } as Foo | undefined

pipe(foo, (f) => f?.bar?.buzz) // undefined
Enter fullscreen mode Exit fullscreen mode

To make this work with optional chaining, we only needed to add another question mark. How would this look like using the Option type?

pipe(
  foo,
  O.fromNullable,
  O.map(({ bar: { buzz } }) => buzz),
)
Enter fullscreen mode Exit fullscreen mode

Property 'buzz' does not exist on type 'Fizz | undefined'.ts (2339)

Sadly, we run in the same problem we had before. That is, object destructuring cannot be used over a type that is possibly undefined.

What we can do is lift both foo and bar into Option types using O.fromNullable twice.

pipe(
  foo,
  O.fromNullable,
  O.map(({ bar }) =>
    pipe(
      bar,
      O.fromNullable,
      O.map(({ buzz }) => buzz),
    ),
  ),
) // { _tag: 'Some', value: { _tag: 'None' } }
Enter fullscreen mode Exit fullscreen mode

But now we've created two new problems. First, it's horribly verbose. Second, we have a nested Option. Look at the _tag of both the outer and inner Option. The first one is Some, which we expect because foo.bar is defined. The second one is None because foo.bar.buzz is undefined. If you only cared about the result of the final Option, you would need to traverse the Option's nested list of tags every time.

Given that we only care about the final Option, could we flatten this nested Option into a single Option?

Introducing the O.flatten operator.

pipe(
  foo,
  O.fromNullable,
  O.map(({ bar }) =>
    pipe(
      bar,
      O.fromNullable,
      O.map(({ buzz }) => buzz),
    ),
  ),
  O.flatten,
) // { _tag: 'None' }
Enter fullscreen mode Exit fullscreen mode

We now have a single Option, { _tag: 'None' } that represents the last Option in the pipeline. If we wanted to check whether this Option was Some or None, we can pipe the result into O.some or O.none.

However, we still have the problem of verbosity. It would be beneficial if we could both map and flatten the nested option with a single operator. Intuitively, this is often called a flatmap operator.

Chain (Flatmap)

In fp-ts, the flatmap operator is called chain. We can refactor the above code into the following.

pipe(
  foo,
  O.fromNullable,
  O.map(({ bar }) => bar),
  O.chain(
    flow(
      O.fromNullable,
      O.map(({ buzz }) => buzz),
    ),
  ),
) // { _tag: 'None' }
Enter fullscreen mode Exit fullscreen mode

In short, we achieve the same result with less.

Conclusion

In most cases, you won't need to use Option; optional chaining is less verbose. But the Option type is more than just checking for null. Options can be used to represent failing operations. And just like how you can lift undefined into an Option, you can also lift an Option into another fp-ts container, like Either.

Checkout the official Option documentation for more info.

Top comments (0)