What should and shouldn't be handled with Reader

fes300

Federico Sordillo

Posted on February 24, 2020

What should and shouldn't be handled with Reader

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.

💖 💪 🙅 🚩
fes300
Federico Sordillo

Posted on February 24, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related