Should I use fp-ts Option?
Anthony G
Posted on September 15, 2020
tl;dr
Option
is more powerful than nullable but can be cumbersome to work with if you aren’t using it consistently. New apps should use Option
everywhere but it can sometimes be a hassle to mix it into existing apps.
Contents
- What is Option?
- The Problem
- What Option does well
- Trying to get an intuition
- Final Thoughts
- Appendix: Uses for Option
What is Option?
If you aren't sure, here are links to simple lessons that I like:
The Problem
Nullables are simple. (For this article, I'll be using the term 'nullable' to mean 'union type with undefined')
const multiplyNullableByTwo = (
num: number | undefined
): number | undefined => num ? num * 2 : undefined
Option
is even simpler.
import * as O from 'fp-ts/Option'
import { pipe } from 'fp-ts/pipeable'
const multiplyOptionalByTwo: (
num: O.Option<number>
) => O.Option<number> = O.map(n => n * 2)
But what if we're adding fp-ts to an app that already uses lots of nullables?
const multiplyOptionalByTwo: (
num: number | undefined
) => O.Option<number> = ????
We have to upconvert using O.fromNullable
import { flow } from 'fp-ts/function'
const multiplyNullableByTwo: (
num: number | undefined
) => O.Option<number> = flow(
O.fromNullable,
O.map(n => n * 2)
)
But should we return an Option
? It makes multiplyNullableByTwo
less interoperable with the existing code that uses nullables. It might be confusing for other devs who don't know fp-ts
, or incoming devs who aren't sure why Option
is used in some places and nullable elsewhere.
Should we convert back?
const multiplyNullableByTwo: (
num: number | undefined
) => number | undefined = flow(
O.fromNullable,
O.map(n => n * 2),
O.toUndefined,
)
In this case, it's definitely not worth the hassle. We should just use the nullable approach from the beginning of this section.
But where's the line? When is Option
more appropriate than nullable?
What Option does well
if
statements are are nowadays considered brittle and poorly legible, and Option
helps simplify control flow.
Let's say you want to write a function called greet
that turns certain authors' names lowercase, and refers to everyone else by their last name. The following has already been implemented (Note that lastName
returns a nullable):
const lowercase = (a: string): string => a.toLowerCase()
const isAuthor = (a: string): boolean => lowercase(a).includes('bell hooks') || lowercase(a).includes('pattrice jones')
const lastName = (a: string): string | undefined => a.split(' ').length > 1 ? a.split(' ')[1] : undefined
Here is the traditional implementation using plain nullables:
const greet = (name: string | undefined): string => {
let modifiedName: string | undefined = undefined
if (name) {
if (isAuthor(name)) {
modifiedName = lowercase(name)
} else {
const last = lastName(name)
if (last) {
modifiedName = last
}
}
}
if (modifiedName) {
return `Hello ${modifiedName}`
}
return 'Greetings!'
}
This version has mutable state (which we normally want to avoid), convoluted control flow, and multiple exit points, which are controversial at best.
Here's the same function implemented with Option
:
const greet = (name: string | undefined): string => pipe(
O.fromNullable(name),
O.chain(O.fromPredicate(isAuthor)),
O.map(lowercase),
O.alt(() => pipe(
O.fromNullable(name),
O.chain(flow(
lastName,
O.fromNullable,
)),
)),
O.map(n => `Hello ${n}`),
O.getOrElse(() => 'Greetings!'),
)
This version is shorter even including the extra conversions with fromNullable
.
The Option
version is easier to read. Our Option
operations give us clues to what's going on:
-
fromPredicate
:isAuthor
is a predicate -
alt
:lastName
is applied as an alternative tolowercase
-
chain
:lastName
might fail -
getOrElse
: return 'Greetings!' if anything goes wrong
It's an example of expression-oriented programming. This means that every line of code has a return value as opposed to executing a side effect. This mitigates information loss and discourages unnecessary side effects.
We could do something like this:
const greet = (name: string | undefined): string => {
const modifiedName = name
? isAuthor(name)
? lowercase(name)
: lastName(name)
? lastName(name)
: undefined
: undefined
if (modifiedName) {
return `Hello ${modifiedName}`
}
return 'Greetings'
}
This approach, while shortest, is probably the least legible of the three. Related cases are spread far apart, it's not obvious why lastName
is called twice, and nested ternaries are often disabled by eslint anyway.
This article's appendix details many useful things that Option
can do better than nullable.
Trying to get an intuition
Ok, we get it, Option
is more powerful than nullable. But that doesn't change the fact that nullable is a very common pattern and can often lead to simpler, shorter solutions.
How can I know when it will be worth the hassle? Should I store Option
s in my data model? Even if my old code still uses nullable all over the place?
This is tricky to think about. I think this question gets at a fundamental paradox about functional programming generally: simple problems become hard and hard problems become simple. Maybe in this case, it's better framed as simple problems becoming slightly awkward and not being sure whether your simple problem will eventually become a hard problem that we can turn into an simple problem.
Functional programming represents an intersection of academic research and practical engineering. What's exciting about fp-ts
is that it's the functional Typescript library that has made the fewest compromises for engineering's sake. This gives it power, but also rigidity. In this regard, fp-ts is easy, but not simple (as defined by Rich Hickey, the author of Clojure).
Maciej Sikora's wonderful article Maybe just Nullable? posits that Option
should be avoided unless the entire application is implemented using functional techniques.
For me, within an application it's often worth the small amount of pain to up-convert into Option
. Hopefully you can use the above example and the appendix below as tools to predict whether your data would benefit from being represented as Option
in the long run.
However, if you're exposing an interface that many other developers will use, and many of them won't be using fp-ts
, it's probably best to save them the hassle and simply expose undefined
. This could be an npm module, or just a project where all the devs aren't entirely comfortable with fp concepts.
Final Thoughts
In an impure language like Typescript, your code will never be fully functional. There are necessarily shades of fp gray along the Typescript spectrum. Choosing to use React pushes your project towards the functional side, or at least more so than choosing Angular or Vue. Choosing lodash shifts slightly further, although even vanilla es6 has taken great inspiration from fp paradigms (by way of lodash and underscore). Maybe you'll go crazy and wrap all your side effects in IO for referential transparency (or maybe that's going sane!)
Adding fp-ts
to a project will make waves. It can be particular and finicky, more so than a library like lodash
which can integrate more seamlessly. Like any restriction, the benefit is more power.
Ultimately, it's up to you how functional you want your code to be. It can be difficult to convince your team to make the switch, and migrating an entire existing project can be beyond reach. In this author's opinion, even in small doses, it's worth the inconvenience, but in any case hopefully this article gives you enough context to make informed decisions.
If you have a specific case where you are still having trouble deciding, please leave a comment or tweet at me about it - sometimes these are difficult questions and I'd love to have longer conversations about them!
Appendix: Uses for Option
Converting to and from Either
Can be useful if error messages are unimportant, or if decide you want to add one later on:
import * as E from 'fp-ts/Either'
const parseIntResult: E.Either<string, number> = E.right(3)
const discardErrorMessage: O.Option<number> = O.fromEither(parseIntResult)
const addErrorMessage: E.Either<string, number> = pipe(
discardErrorMessage,
E.fromOption(() => 'could not parse to number'),
)
Safe Array Operations
This PR to Typescript was closed, so this is dangerous:
const names: string[] = ['anthony', 'gabriele']
const unsafeThirdName: string = names[2]
Unfortunately, this won't even crash at runtime. unsafeThirdName
will merely be set to 'undefined' even though the type doesn't reflect that. It will run as though nothing is wrong until unsafeThirdName
is used.
Option
is safer.
import * as A from 'fp-ts/Array'
const safeThirdName: O.Option<string> = pipe(
names,
A.lookup(2)
)
There are many safe Array
functions that take advantage of Option
:
(Edit: Good news! Ryan Lee pointed out in the comments that Typescript 4.1 will fix this issue by providing a new compiler flag, which can be set on the command line like this: tsc --noUncheckedIndexedAccess
or by setting "noUncheckedIndexedAccess": true
in the compilerOptions
of your tsconfig.json
(thanks Ryan!). At the time of writing (16/9/2020), it hasn't been folded into a stable build of Typescript yet, but it is in the latest nightly build. You can learn more about the flag in its PR here)
(This leaves us with a bit less justification for using Option
in a project where fp-ts
usage is not ubiquitous, since A.lookup
and R.lookup
are now actually slightly more cumbersome to use than the new nullable syntax.)
(However, this assumes that your Typescript version is greater than version 4.1 and that you have noUncheckedIndexedAccess
enabled. We will see if and when those things get folded into common use with tools like create-react-app
which is still currently using version 3.7.5 (nearly a year old), ng create
which uses 3.8.3, etc.)
Array of Option
Let's say we have an array of optional names:
const names: O.Option<string>[] = [O.some('anthony'), O.none, O.some('gabriele')]
If we want to remove all empty values, we can use:
const existingNames: Array<string> = pipe(
names,
A.compact
)
// existingNames = ['anthony', 'gabriele']
To ensure the array contains no empty values, we can use:
const allNamesFailure: O.Option<Array<string>> = pipe(
names,
A.sequence(O.option)
)
// allNamesFailure = O.none
const allNamesSuccess: O.Option<Array<string>> = pipe(
[O.some('anthony'), O.some('gabriele')],
A.sequence(O.option)
)
// allNamesSuccess = O.some(['anthony', 'gabriele'])
sequence
is used to 'flip' nested generic types inside out - here, we have gone from Array<O.Option<string>>
to O.Option<Array<string>>
Tuple of Option
If we want to ensure a tuple has no empty values, we can't use A.sequence
, since the internal types can be different from each other:
const nameAndAge: [O.Option<string>, O.Option<number>] = [O.some('anthony'), O.some(26)]
const bothNameAndAge: O.Option<[string, number]> = pipe(
nameAndAge,
A.sequence(O.option),
)
// error: Type '[Option<string>, Option<number>]' is not assignable to type 'Option<string>[]'.
You can use sequenceT
in this case:
import { sequenceT } from 'fp-ts/Apply'
const printSelf: string = pipe(
sequenceT(O.option)(...nameAndAge),
O.map(
([name, age]: [string, number]) =>
`in five years, ${name} will be ${age + 5}`
),
O.getOrElse(() => 'missing name or age'),
)
Safe Record Operations
There are similar functions for Record
as for Array
:
import * as R from 'fp-ts/Record'
const block: Record<string, number> = {
buildings: 5,
streets: 4
}
const unsafeNumBuildings: number = block.buildings
const safeNumBuildings: O.Option<number> = pipe(
block,
R.lookup('buildings')
)
const ingredients: Record<string, O.Option<number>> = {
onions: O.some(2),
peppers: O.none,
eggs: O.some(3),
}
const whateverOmlette: Record<string, number> = pipe(
ingredients,
R.compact,
)
const westernOmlette: O.Option<Record<string, number>> = pipe(
ingredients,
R.sequence(O.option),
)
Chaining And Using Several Optional Values In Sequence
If you find yourself with the deeply nested chain
of doom:
const addFour: number = pipe(
O.some(6),
O.chain(first => pipe(
O.some(1),
O.chain(second => pipe(
O.some(first + 7),
O.chain(third => pipe(
O.some(4),
O.map(fourth => first + second + third + fourth),
))
)),
)),
)
You can approximate Haskell's Do
notation like this:
const addFour: number = pipe(
O.some(6),
O.bindTo('first'),
O.bind('second', () => O.some(1)),
O.bind('third', ({ first }) => O.some(first + 7)),
O.bind('fourth', () => O.some(4))
O.map(({ first, second, third, fourth }) => first + second + third + fourth)
)
Much cleaner!
Accessing and Modifying Deeply Nested Data
Option
can help you easily access & modify deeply nested data using monocle-ts
:
import * as Op from 'monocle-ts/lib/Optional'
interface Employee {
name: string
company: {
name: string
address: {
city: string
street: O.Option<{
num: number
name: string
}>
}
}
}
const companyStreetName: Op.Optional<Employee, string> = pipe(
Op.id<Employee>(),
Op.prop('company'),
Op.prop('address'),
Op.prop('street'),
Op.some,
Op.prop('name')
)
const employee: Employee = ...
const employeeCompanyStreetName: O.Option<string> =
companyStreetName.getOption(employee)
const employeeAfterMove: Employee = pipe(
employee,
companyStreetName.set('New Street Name')
)
getOption
is Option
's version of nullable's optional chaining (which is a confusing name in this context because nullable is not Option
), but set
has no such equivalent.
Posted on September 15, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024