The Node reverse proxy explained in my last post sprinkled in a little bit of functional TypeScript through Ramda. 1 This naturally gave me an excuse to talk about the project at the functional programming group at work, but some of the feedback that I got was that Ramda takes advantage of the permissive nature of JavaScript types, so fp-ts
is a better functional library for TypeScript.2 Professor Frisby's Mostly Adequate Guide to Functional Programming is linked by the fp-ts
docs in lieu of a more comprehensive tutorial for the library, and it is a wonderful resource for functional programming in JavaScript. 3 Chapters 8 and 9 explain functors and monads respectively, and after reading them I told a developer friend of mine 'I recently made a breakthrough in understanding monads' before sending him some notes explaining why. Said notes are available here, but the next morning I stared at the relevant type signatures long enough to understand that nearly everything about my explanation was wrong. This post is an effort to get it right.
Some say that once you understand monads you lose the ability to explain them. 4 This can't bode well for this explanation, but let's give it a shot. A better question than 'what are monads and why would you use them' is 'what are the practical differences between monads and functors'. Functors by themselves are pretty powerful: the Mostly Adequate guide explains them well, the gist being 'A Functor is a type that implements map
and obeys some laws', with said laws shown below:
// identity
map(id) === id;
// composition
compose(map(f), map(g)) === map(compose(f, g));
const compLaw1 = compose(map(append(" romanus ")), map(append(" sum")));
const compLaw2 = map(compose(append(" romanus "), append(" sum")));
compLaw1(Container.of("civis")); // Container("civis romanus sum")
compLaw2(Container.of("civis")); // Container("civis romanus sum")
The rest of this post will assume familiarity with the Either
, Option
, and IO
types, the latter of which is implemented in fp-ts/IO
. Note that all three of these types are both monads and functors, as all monads are functors but not vice versa. The characteristic function of functors is map
, which calls a function on the value contained within the functor. The IO type specific map
function has been imported as mapIO
below:
import { pipe } from "fp-ts/function";
import { IO, chain as chainIO, map as mapIO, of as ofIO } from "fp-ts/IO";
const effect: IO<string> = ofIO("myString");
const singleMap: (fa: IO<string>) => IO<string> = mapIO((input: string) =>
input.toUpperCase(),
);
const singleMapFunction: IO<string> = singleMap(effect);
const singleMapApplied: string = singleMapFunction(); // = "MYSTRING"
In the segment above, the argument to mapIO
is a function that accepts a string and returns another string. The return value is itself a function, and it accepts an IO<string>
and returns another IO<string>
. This can be useful to sequentially apply multiple successive functions on the functor's value. While this can look messy in languages without a pipeline operator, the fp-ts
pipe function can clean this up making doubleMapMessy
and doubleMapPipe
equivalent in the following.
const doubleMapMessy: IO<string> = mapIO((input: string) =>
input.toUpperCase(),
)(mapIO((input: string) => input.repeat(2))(effect));
const doubleMapPipe: IO<string> = pipe(
effect,
mapIO((input: string) => input.toUpperCase()),
mapIO((input: string) => input.repeat(2)),
);
const doubleMapPipeApplied: string = doubleMapPipe()// = "MYSTRINGMYSTRING"
While all the functions provided to mapIO
thus far have had types IO<string> => IO<string>
, note the type signature of mapIO
:
export declare const map: <A, B>(f: (a: A) => B) => (fa: IO<A>) => IO<B>
This means that we can use map
with string => void
functions, which would be appropriate for logging to standard output. To make things clearer, I've added some type information in the segment below.
const mapWithSideEffect: IO<void> = pipe(
effect,
// IO<string>
mapIO((input: string) => input.toUpperCase()),
//(f: (a: A) => B) => (fa: IO<A>) => IO<B>; A: string, B: string
mapIO((input: string) => input.repeat(2)),
//(f: (a: A) => B) => (fa: IO<A>) => IO<B>; A: string, B: string
mapIO((input: string) => console.log(input)),
//(f: (a: A) => B) => (fa: IO<A>) => IO<B>; A: string, B: void
);
//Logs "MYSTRINGMYSTRING"
mapWithSideEffect();
Given all of this, why bother with monads? While we've only seen examples from the IO monad, everything shown here would allow you to convert Option<string>
to Option<number>
while deserialising a property that may not exist, or from Result<UserValidationError, User>
to Result<BalanceValidationError, Balance>
.
A problem arises when we need to combine different IO
operations. Let's say we have a logFilePath
in myConfig.json
. It is entirely reasonable for an application to read the log file path from the configuration file before writing to the log, but map functions aren't meant to handle this. Strangely, the segment below will compile even though the type hint for pureMapWriteToConfig
is incorrect: it should be IO<() => void>
instead as shown by the related comment. Because of this, mapWriteToConfig()
doesn't actually log anything, which shouldn't have surprised us in the first place.
import { pipe } from "fp-ts/function";
import { IO, chain as chainIO, map as mapIO, of as ofIO } from "fp-ts/IO";
import { readFileSync, writeFileSync } from "node:fs";
interface Config {
logFilePath: string;
}
const getFileJson =
(fileName: string): IO<Config> =>
() =>
JSON.parse(readFileSync(fileName, "utf-8")) as Config;
const pureMapWriteToConfig = (configFileName: string, log: string): IO<void> =>
pipe(
getFileJson(configFileName),
mapIO((config: Config) => {
console.log(`Map config: ${JSON.stringify(config)}`);
return config;
}),
mapIO((config: Config) => () => writeFileSync(config.logFilePath, log)),
// mapIO<Config, () => void>(f: (a: Config) => () => void): (fa: IO<Config>) => IO<() => void>
);
const mapWriteToConfig = pureMapWriteToConfig("mapConfig.json", "myMapLog");
mapWriteToConfig();
/*
$ wc -l mapLog.txt
0 mapLog.txt
*/
Once we have the config file represented as an IO<Config>
instance, we need to read it and do an IO operation for the log file. That means we need some function that can accept a (config: Config) => IO<void>
as well as the incoming IO<Config>
. This is known by a few different names, including bind
and flatMap
, but fp-ts
calls this chain
. This is the characteristic function that separates monads from non-monad functors:
export declare const chain: <A, B>(f: (a: A) => IO<B>) => (ma: IO<A>) => IO<B>
The chain
function is imported as chainIO
, so chainWriteToConfig
logs successfully when called.
const pureChainWriteToConfig = (
configFileName: string,
log: string,
): IO<void> =>
pipe(
getFileJson(configFileName),
mapIO((config: Config) => {
console.log(`Chain config: ${JSON.stringify(config)}`);
return config;
}),
chainIO((config: Config) => () => writeFileSync(config.logFilePath, log)),
//chainIO<Config, void>(f: (a: Config) => IO<void>): (ma: IO<Config>) => IO<void>
);
const chainWriteToConfig = pureChainWriteToConfig(
"chainConfig.json",
"myChainLog",
);
chainWriteToConfig();
/*
$ cat chainLog.txt
myChainLog
*/
Chapter 9 of the Mostly Adequate guide explains monads somewhat differently. They introduce join
as the flattening of Monad<Mondad<T>>
into Monad<T>
making chain
equivalent to a map
followed by a join
. The examples they provided use function composition rather than pipes, but this doesn't change much.
A few takeaways
pureMapWriteToConfig
type inference failure is unsettling and has made me lose some confidence in how the TypeScript compiler handles fp-ts
.map
and chain
functions in fp-ts
, but it is my understanding that this isn't necessary in Haskell, allowing for $
and >==
operators to map and bind respectively.Either
or Option
instances, so in pure languages you're forced to recon with this sooner.pureMapWriteToConfig
and pureChainWriteToConfig
. While many discussions of monads will reference carrying out IO or mutable state without breaking functional purity, this isn't all that helpful in explaining what monads do given a) functors enable similar behaviour and b) chain
/bind
can be useful in situations where no side effects are carried out.fp-ts
NPM Package. Note: fp-ts
will appear in code segments in this article, consistent with the package documentation↩︎
Mostly Adequate Guide to Functional Programming. Note: it appears that Brian Lonsdorf started the project, but there are many other contributors to the current repository↩︎
Unfortunately I have forgotten where I first read this, it isn't my original quip↩︎