DEV Community

loading...

Should I use fp-ts Option?

anthonyjoeseph profile image Anthony G ・Updated on ・10 min read

Definitely Option

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?

If you aren't sure, here are links to simple lessons that I like:

  • pipe (good to understand this before diving into Option)
  • Option

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

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

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

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

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

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

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

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

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 to lowercase
  • 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'
}
Enter fullscreen mode Exit fullscreen mode

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

Safe Array Operations

This PR to Typescript was closed, so this is dangerous:

const names: string[] = ['anthony', 'gabriele']
const unsafeThirdName: string = names[2]
Enter fullscreen mode Exit fullscreen mode

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

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

If we want to remove all empty values, we can use:

const existingNames: Array<string> = pipe(
  names,
  A.compact
)

// existingNames = ['anthony', 'gabriele']
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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.

Discussion (8)

pic
Editor guide
Collapse
euphocat profile image
Nicolas Baptiste

Wow ! Great article !! Clear and human-friendly, which is not often the case when dealing with fp-ts :D

Collapse
anthonyjoeseph profile image
Anthony G Author

Thank you! Yes I'm excited to keep writing about fp-ts because I think its verbosity and proximity to JS can actually make it easier to read and can encourage deeper reasoning about fp than languages with terse, high-level syntax. In case you're having trouble finding great articles about fp-ts (of which there are many imo), here's a great resource: gcanti.github.io/fp-ts/learning-re...

Collapse
macsikora profile image
Collapse
anthonyjoeseph profile image
Anthony G Author • Edited

I really like your article! Especially the in-depth detail of the nuances of JS/Typescript nullable and its usage. It was a major inspiration for me when I was doing research. Hopefully readers will see the link in the article & this comment and take a look!

Collapse
macsikora profile image
Maciej Sikora

Ah. I didn't see that before. Thank you for mentioning and it's great that it has helped. Good work by the way!

Thread Thread
anthonyjoeseph profile image
Collapse
ryanleecode profile image
Ryan Lee

I think you are mistaken about safe array operations, a PR just got merged into Typescript 4.1.

github.com/microsoft/TypeScript/pu...

Collapse
anthonyjoeseph profile image
Anthony G Author

Oh wow I had no idea, how exciting! I updated the article to reflect this. Thanks for pointing this out!