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

Table of Contents 🔗
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>
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
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
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)
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' }
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
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),
)
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' } }
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' }
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' }
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.