Chehan Ratnasiri

A room full of pipes in the style of van gogh (AI Art)

fp-ts: Why?

Published on
Last updated on

Why?

Why takes precedence over How in the beginning stages of learning anything. Therefore, before diving into the details of how to use fp-ts, it’s important to first understand why one should consider using it.

Safety

Take a look at the following type definition:

type FindItemFn = (items: string[], index: number) => string;

Given an array of strings, this function will attempt to return an item residing on a particular index. The returned item will be a string.

Let’s implement this function and test it out:

const findItem: FindItemFn = (items, index) => {
  return items[index];
};

const items = ["foo", "bar", "baz"];

findItem(items, 0); // 'foo'

findItem(items, 2); // 'baz'

But what happens if we try to find an item that doesn’t exist?

findItem(items, 5); // undefined

Even though we specified that the return type should be a string, we can sometimes get undefined when the code is executed. This can lead to unexpected errors and bugs.

Moreover, the above implementation doesn’t handle null or undefined values, and it doesn’t provide any information about why a value may be missing. This is where functional programming and the Option monad from the fp-ts package come into play.

Functional programming is a paradigm that emphasises the use of immutable values and pure functions to avoid side effects and make programs more predictable and easier to grok. One of the key concepts in functional programming is the use of monads.

What’s a monad?

A monad is a way of representing computations that can be composed together using a simple set of operations. Think of them as classes that only contain static methods.

For example, the Option monad in fp-ts can be used to represent computations that may or may not return a value.

Here’s the type definition for the findItem function using the Option monad:

import { Option } from "fp-ts/Option";

type FindItemFn = (items: string[], index: number) => Option<string>;

Now let’s implement the FindItemFn function again using the new type definition:

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

const findItem: FindItemFn = (items, index) => {
  const item = items[index];

  if (item) return some(item);
  else return none;
};

Here, we’re using the some function to wrap the value of the item variable in an Option if it exists, and the none function to return an empty Option if it doesn’t.

By returning an Option instead of a nullable value, we ensure that any code that uses the result of findItem must explicitly handle the possibility that the value may not exist. This helps to avoid null pointer errors and makes our code more predictable and easier to understand.

Let’s see what happens when we call our new findItem function:

const items = ["foo", "bar", "baz"];

findItem(items, 0); // { _tag: 'Some', value: 'foo' }

findItem(items, 2); // { _tag: 'Some', value: 'baz' }

findItem(items, 5); // { _tag: 'None' }

In the first two examples, findItem returns a Some object that wraps the string value of the item that was found at the specified index. In the last example, findItem returns a None object, indicating that there is no item at the specified index. By using the Option monad, we have made it impossible to accidentally return a null or undefined value from this function, which reduces the likelihood of runtime errors and makes our code more reliable.

Functions

The fp-ts package provides a wide range of functions that make it easier to work with common data types.

import { lookup } from "fp-ts/Array";

const findItem: FindItemFn = (items, index) => {
  return lookup(index)(items);
};

const items = ["foo", "bar", "baz"];

findItem(items, 0); // { _tag: 'Some', value: 'foo' }

findItem(items, 2); // { _tag: 'Some', value: 'baz' }

findItem(items, 5); // { _tag: 'None' }

In the above code block, we imported the lookup function from the Array monad of the fp-ts package and used it to implement the findItem function in a simpler way. The lookup function takes an index and an array as its arguments and returns an Option object containing the element at the specified index if it exists, or None if it does not.

By using the lookup function, we have eliminated the need to manually create Option objects and check for undefined values, making the code simpler and easier to read.

Let’s look at another one of these functions:

type ChunkArrayFn = <T>(items: T[], chunkSize: number) => T[][];

const chunkArray: ChunkArrayFn = (items, chunkSize) => {
  const result = [];

  for (let i = 0; i < items.length; i += chunkSize) {
    result.push(items.slice(i, i + chunkSize));
  }

  return result;
};

chunkArray([1, 2, 3, 4, 5, 6, 7], 2); // [ [ 1, 2 ], [ 3, 4 ], [ 5, 6 ], [ 7 ] ]

Above is a TypeScript implementation of a function chunkArray that takes an array of items and a chunk size as parameters, and returns an array of arrays where each subarray contains a maximum of chunkSize items. The implementation works by using a loop to slice the original array into chunks of size chunkSize, which are then pushed to a result array.

import { chunksOf } from "fp-ts/Array";

const chunkArray: ChunkArrayFn = (items, chunkSize) => {
  return chunksOf(chunkSize)(items);
};

chunkArray([1, 2, 3, 4, 5, 6, 7], 2); // [ [ 1, 2 ], [ 3, 4 ], [ 5, 6 ], [ 7 ] ]

Here we improved the first implementation by using the chunksOf function from Array to achieve the same result with a more concise and readable implementation.

There are numerous functions available in fp-ts, some more useful than others. Consider taking a look at the docs of your favourite monad if you wish to simplify your code.

Composability

Composability refers to the ability to combine small, reusable building blocks together to create more complex functionality. In the context of functional programming, this means using higher-order functions and other functional abstractions to build up functionality from smaller pieces of code.

What’s a higher-order function?

It is a function that takes one or more functions as arguments or returns a function as its result. In other words, a higher-order function is a function that operates on functions.

type ApplyTwiceFn = <T>(fn: (value: T) => T, value: T) => T;

const applyTwice: ApplyTwiceFn = (fn, value) => {
  return fn(fn(value));
};

type Add3Fn = (num: number) => number;

const add3: Add3Fn = (num) => num + 3;

applyTwice(add3, 2); // 8

If you have used map, filter or reduce on an array, you have already used a higher-order function.

How can we use higher-order functions with fp-ts?

With pipes!

Functional programming is all about creating reusable, modular, and composable code. One of the key tools in achieving this is the pipe function provided by fp-ts. pipe allows functions to be easily chained together.

For example, consider the following code:

const numbers = [1, 2, 3, 4, 5];

const evenNumbers = numbers.filter((v) => v % 2 === 0);
const squaresOfEvenNumbers = evenNumbers.map((v) => v ** 2);
const sumOfSquaresOfEvenNumbers = squaresOfEvenNumbers.reduce(
  (acc, v) => acc + v,
  0
); // 20

Here we used intermediate variables to obtain sumOfSquaresOfEvenNumbers for the given array of numbers.

import { pipe } from "fp-ts/function";
import { filter, map, reduce } from "fp-ts/Array";

const numbers = [1, 2, 3, 4, 5];

const sumOfSquaresOfEvenNumbers = pipe(
  numbers,
  filter((v) => v % 2 === 0),
  map((v) => v ** 2),
  reduce(0, (acc, v) => acc + v)
); // 20

And here we used pipe to compose a series of functions together from the Array monad to calculate the sumOfSquaresOfEvenNumbers.

We can now rewrite our applyTwice function using pipe to avoid nesting function calls.

import { pipe } from "fp-ts/function";

const applyTwice: ApplyTwiceFn = (fn, value) => {
  return pipe(value, fn, fn);
};

One of the primary benefits of using pipe is readability. By providing a clean and concise syntax for chaining functions together, pipe makes it easier to follow the flow of data through a program. This clarity can make code easier to understand, maintain, and modify over time.

Another important benefit of pipe is reusability. Because functions can be easily chained together, it’s easy to create modular functions that can be reused throughout an application. This can save time and reduce code duplication, making development more efficient and effective.

import { pipe } from "fp-ts/function";
import { filter, map, reduce } from "fp-ts/Array";

const numbers = [1, 2, 3, 4, 5];

const filterEven = (items: number[]) =>
  filter<number>((v) => v % 2 === 0)(items);

const squareItems = (items: number[]) =>
  map<number, number>((v) => v ** 2)(items);

const getSum = (items: number[]) =>
  reduce<number, number>(0, (acc, v) => acc + v)(items);

const squaresOfEvenNumbers = pipe(numbers, filterEven, squareItems); // [4, 16]

const sumOfSquaresOfEvenNumbers = pipe(squaresOfEvenNumbers, getSum); // 20

Finally, the modularity and clarity of code that pipe provides can also make it easier to write unit tests. By breaking down functionality into smaller, more focused functions, it becomes simpler to test individual components of a program in isolation, making it easier to ensure code quality and correctness.

Wrap up

We explored the reasons why one should consider using the fp-ts package. We discussed how functional programming and the Option monad from fp-ts can help write safer code by eliminating the possibility of accidentally returning null or undefined values. Additionally, we examined how fp-ts offers an extensive library of functions that simplify working with common data types. Apart from the benefits of safety and functions, fp-ts also offers significant composability advantages, resulting in code that is more concise, expressive, and easier to comprehend.