Skip to content
Giulio Canti edited this page Apr 21, 2023 · 12 revisions

How-to

Problem

I want to map a string array to an integer array and compute the total sum. Mapping a string to an int implies that the string might be a number, but it might not as well. So, question is, how do I structure the program so I can handle the resulting exception when a string is actually not a number? How to handle exceptions properly in a functional style?

Solution

import * as assert from 'assert'
import { pipe } from 'fp-ts/function'
import * as M from 'fp-ts/Monoid'
import * as N from 'fp-ts/number'
import * as O from 'fp-ts/Option'
import * as RA from 'fp-ts/ReadonlyArray'

const parseInteger = (s: string): O.Option<number> => {
  const n = parseInt(s, 10)
  return isNaN(n) ? O.none : O.some(n)
}

const getSum: (numbers: ReadonlyArray<number>) => number = M.concatAll(
  N.MonoidSum
)

const solution = (input: ReadonlyArray<string>): O.Option<number> => {
  // const parsing: readonly O.Option<number>[]
  const parsing = pipe(input, RA.map(parseInteger))
  // const numbers: O.Option<readonly number[]>
  const numbers = pipe(parsing, RA.sequence(O.Applicative))
  // const sum: O.Option<number>
  const sum = pipe(numbers, O.map(getSum))
  return sum
}

assert.deepStrictEqual(solution(['1', '2', '3']), O.some(6))
assert.deepStrictEqual(solution(['1', 'a', '3']), O.none)

You can rewrite the solution to a single pipeline

const solution = (input: ReadonlyArray<string>): O.Option<number> => {
  return pipe(
    input,
    RA.map(parseInteger),
    RA.sequence(O.Applicative),
    O.map(getSum)
  )
}

Note that map + sequence = traverse, you can refactor the pipeline to

const solution = (input: ReadonlyArray<string>): O.Option<number> => {
  return pipe(input, RA.traverse(O.Applicative)(parseInteger), O.map(getSum))
}

Alternatively if you just want to skip bad inputs but keep adding good ones

const solution = (input: ReadonlyArray<string>): number => {
  return pipe(input, RA.filterMap(parseInteger), getSum)
}

assert.deepStrictEqual(solution(['1', '2', '3']), 6)
assert.deepStrictEqual(solution(['1', 'a', '3']), 4)

Optionally since input is repeated you can get rid of pipe and use flow

import { flow } from 'fp-ts/function'

const solution: (input: ReadonlyArray<string>) => number = flow(
  RA.filterMap(parseInteger),
  getSum
)

Problem

I have two Option<T[]> and want to operate on the concatenated value of both, i.e. for O.some([1, 2]) and O.some([3]) I'd like to work on [1, 2, 3] while in the case any of the two is O.none I still want the value of the other. What's the most convenient way to concatenate those?

Solution

import * as assert from 'assert'
import * as O from 'fp-ts/Option'
import * as RA from 'fp-ts/ReadonlyArray'

const solution = <A>(
  o1: O.Option<ReadonlyArray<A>>,
  o2: O.Option<ReadonlyArray<A>>
): O.Option<ReadonlyArray<A>> => {
  return O.getMonoid(RA.getSemigroup<A>()).concat(o1, o2)
}

assert.deepStrictEqual(solution(O.some([1, 2]), O.some([3])), O.some([1, 2, 3]))
assert.deepStrictEqual(solution(O.none, O.some([3])), O.some([3]))
assert.deepStrictEqual(solution(O.some([1, 2]), O.none), O.some([1, 2]))

Footguns

APIs returning a sentinel value

import { pipe } from 'fp-ts/function'

const doSomethingWithIndex = (n: number): number => n * 2

pipe(
  ['a', 'b', 'c'].findIndex((s) => s.length > 2), // no error
  doSomethingWithIndex,
  console.log
) // => -2, because findIndex returns -1 :facepalm: the type checker can't help us here

import * as RA from 'fp-ts/ReadonlyArray'

pipe(
  ['a', 'b', 'c'],
  RA.findIndex((s) => s.length > 2), // error
  doSomethingWithIndex,
  console.log
)

APIs returning undefined

import { pipe } from '../src/function'

pipe(
  ['a', undefined, 'c'].find((s) => s === undefined || s === 'c'),
  console.log
) // undefined (<= what does it means? Did it found an element or not?)

import * as RA from 'fp-ts/ReadonlyArray'

pipe(
  ['a', undefined, 'c'],
  RA.findFirst((s) => s === undefined || s === 'c'),
  console.log
) // some(undefined) (<= found an element which is `undefined`)

Option or Either?

The problem

Both data types could be used to model partial functions making them total which is a necessary requirement for functional programming. A partial function is not a function in the mathematical sense (the primary goal of functional programming is to model computation via mathematical functions). Both data types can thus be used to model computations that may "fail" in some sense e.g. accessing an out-of-bound index, reading a file from the file system, dividing a number by 0.

Differences in the data types

We can reasonably draw similarities between the Some<A> and Right<A> data types, they are the same very same data type with a different name. In fact, both types are also equivalent to A itself and do not need the additional ceremony of being "wrapped" in a parametric type at all! Neither Right<A> nor Some<A> add any information over A alone.

Thus Right<A> and Some<A> only make sense in the context of being members of the sum types they form along Left<E> in the first case and None in the second case.

Thus, the difference between the Either<E, A> and Option<A> data types is the fact that Left<E> and None, differently from Some<A> and Right<A> are not equivalent at all.

  • None is a unit type. Since it has always the same value: it does not hold any kind of information.

  • Left<E> is a parametric type. It can hold any kind of information of the generic type E.

Differences in practice

Suppose we want to model the function head which given an Array<A> data type will give us the first element of type A.

declare const head: <A>(as: A[]) => A

This function with that signature is impossible to implement (try if you want as an exercise!). It is a partial function.

The value of Array<A> might be [], thus head cannot return a value.

Let's make the function total leveraging the Either<E,A> or Option<A> data types:

import { none, some, Option } from "fp-ts/Option"

const head: <A>(as: A[]) => Option<A> = as => as.length === 0 ? none : some(as[0])

As we can see it is quite trivial to implement a total version of head using the Option data type.

Let's use Either<E, A>.

First of all, we need to find how we want to model the failure, as in the case of Left<E> we do need to provide a value for E. Let's settle for a very expressive Left<"Array is empty">.

import { left, right, Either } from "fp-ts/Option"

const head: <A>(as: A[]) => Either<"Array is empty", A> = as => as.length === 0 ? left("Array is empty") : right(as[0])

There are two major problems with the head function above:

  1. the first regards consumption and implementation of the function: it is not very handy to implement an api for which we have to come up with a value for E in Left<E>.

  2. The second reason is conceptual: Left<"Array is empty"> is a unit type, it does not contain any additional information that None does not! That is: Either<"Array is empty", A> and Option<A> are the very same exact type for all practical and conceptual purposes, with the first one just going through more ceremonies.

But what if I have various computations that may "fail", wouldn't I want to know the reason, the fact that the array is empty might be one of them?

This is a very valid concern that is very common in practice.

Imagine the following scenario, we want to take the first character of the first string an Array<string>: this operation may fail because either the array or the first string is empty.

This could have a very real use case: the array of strings may represent different sentences and we're tasked to capitalize the first letter of every sentence.

import { Option } from "fp-ts/Option"
import { flow } from "fp-ts/Function"

// takes the first element of an Array<A>
declare const head: <A>(as: A[]) => Option<A>

// takes the first character of a string, which again, might be empty
declare const getFirstCharacter: (s: string) => Option<string>

const firstCharacterOfTheFirstString: (ss: string[]) => Option<string> = flow(head, O.flatMap(getFirstCharacter))

As we can see, there is no way to tell whether the array of strings was empty, or the string was empty in case None is returned by firstCharacterOfTheFirstString!

If we want this information, an Either type is necessary.

Let's change the signature of firstCharacterOfTheFirstString:

declare const: firstCharacterOfTheFirstString: (ss: string[]) => Either<"Array is empty" | "String is empty", string>

This may look like a "win" for Either indeed: afterall providing that additional information in Left<E> does provide some benefit.

It would be...if it wasn't for the fact that we can always transform an Option in an Either with a natural transformation (we can also do it the other way around).

Let's see a possible implementation:

import { fromOption, Either, chainW } from 'fp-ts/Either';
import { Option } from 'fp-ts/Option';
import { flow } from 'fp-ts/function';

// notice how both functions return an Option, not an Either
declare const head: <A>(as: A[]) => Option<A>;
declare const getFirstCharacter: (s: string) => Option<string>;

const firstCharacterOfTheFirstString: (ss: string[]) => Either<'Array is empty' | 'String is empty', string> = flow(
  head,
  fromOption(() => 'Array is empty' as const), // as const used to make TS infer the literal 'Array is empty' rather than 'string'
  chainW(
    flow(
      getFirstCharacter,
      fromOption(() => 'String is empty' as const)
    )
  )
);

As we can see going through natural transformations we can achieve all our goals:

  • distinguish why the program has not returned us the first character of the first string
  • preserve the simplest APIs for head and getFirstCharacter without using an awkward API (that contains the same information as Option for the reasons we have seen before)

Conclusion: when to use Either and when to use Option

Option:

  • there is one and only one reason for "failure", as in the case of head or getFirstCharacter. It is not possible to add any additional information because there is None (pun intended), there's one and only one reason those functions can fail (absence of the value).

  • we are not interested in the reason it failed. It may make sense in our program that we only want to handle the "happy case" of indeed having A but we are not interested in handling the reason it failed.

Either:

  • there are multiple reasons for "failure". Not the case of head or getFirstCharacter but definitely the case of firstCharacterOfTheFirstString
  • we do want to handle the different reasons for failure in our program

Notice that fp-ts exposes a natural transformation from Either to Option as well. Even if you have a Left<E> that contains multiple failure reasons you can always decide that you may not need reasons to handle that if it makes sense in your program. There are many reasons, e.g., why an http call, parsing a document or reading a file may fail, but your program may not need to handle those reasons.