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