fp-ts: Why?
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.