Recently I had the chance to discuss with a colleague (friend?) of mine about the Reader monad and why all the examples found in literature seem to always be about implicitly threading a Config
object through your application.
I was frustrated to see every article resort to the same old example and felt there would surely be dozens of other use cases where you could fruitfully put Reader
to use. This frustration led me to a few thoughts that I want to share with everyone in the hope that it may be helpful to other people naively approaching the subject (as I was).
First of all a little intro: the Reader monad in fp-ts: 2.0
is nothing else than an alias of the function profunctor, its signature is:
interface Reader<R, A> {
(r: R): A
}
In fact, this very simple (and powerful) structure allows us to define not only profunctor instances for it but also monadic ones! So feel free to chain on it like there's no tomorrow.
The way this abstraction is usually put into use in codebases is by creating a context that can be accessed at any level of your application without the need to thread it to all the places where it needs to be used:
// src/index.ts
type C = {
i18n: [[k:string]: string],
threads: 2,
dbHost: string
}
const MyApp: Reader<C, unknown>= (c: C) => {...}
// src/repository/entities/arbitraryNesting/../index.ts
import { ask } from 'fp-ts/lib/Reader'
const getById = (id: string) => {
const { dbHost } = ask<C>()
...
}
so what I was thinking is: why just configuration? Why don't we use Reader
every time there is unnecessary chaining of parameters?
In this example (from which I stripped everything not strictly related to the point I am trying to make) I only pass roles
to the function getDocsByRoles
and filter
is retrieved autonomously by getDocsByRole
from the Reader context, thus avoiding defining it explicitly on getDocsByRoles
API:
import { reader, Reader } from "fp-ts/lib/Reader";
import { array } from "fp-ts/lib/Array";
const getDocsByRole = (role: string) => (filter: string): any => {
...
}
const getDocsByRoles = (roles: string[]): Reader<string, any> => {
const docs: Reader<string, any[]> = array.traverse(reader)(roles, getDocsByRole)
return reader.chain(docs, fancyTransformation)
}
const getUserDocs = (request: DocsRequest) => {
const roles = ...
const filter = ...
return getDocsByRoles(roles)(filter);
}
What I did in this example may look right but there is fundamental (and subtle IMHO) flaw: while we gained a little in conciseness by avoiding to chain down the filter
parameter, we lost a lot in terms of readability of the API. In fact, there would be no working implementation of getDocsByRoles
without a filter
and its return value strictly depends on it: although the function does not use it, it is a fundamental part of its ergonomics.
This, in my opinion, is the reason why all the Reader
examples you see around are ones involving App configuration and global contexts.
While db location, secrets, and even i18n are usually details of an app that can be safely hidden from the surface of your internal APIs, other runtime values are usually strictly tied to the behavior of your implementations and, therefore, should not be hidden. It is a matter of ergonomics.
Top comments (2)
interesting point of view, but your second code snippet could benefit from being a little bit more complete: there is no implementation and no return type of getDocsByRole and getDocsByRoles
hi Alessio, thank you very much for your comment :)
this is not the first time I got this feedback so I added some type annotations and part of a possible implementation.
When I wrote the article I really wanted it to keep it as simple as possible as the point I am making is not technical, but apparently it was not the best idea