The following section is not part of monet.js documentation, but I think it's worth showing how we can compose readers using fp-ts.
Let's say we have the following piece of code:
interface Dependencies {
logger: { log: (message: string) => void }
env: 'development' | 'production'
}
const c = ({ logger, env }: Dependencies) => {
logger.log(`[${env}] calling c function`)
return 1
}
const b = (deps: Dependencies) => c(deps) * 2
const a = (deps: Dependencies) => b(deps) + 1
const logger = { log: (message: string) => console.log(message) }
const deps: Dependencies = { logger, env: 'development' }
const result = a(deps)
assert(result === 3)
(I voluntarily didn't use IO for the logger to avoid adding complexity to this example)
As you can see, a and b must have knowledge about c dependencies despite not using them. This adds "noise",
making the code more complex, thus decreasing its readability and maintainability.
Using Reader can improve this part:
import * as R from 'fp-ts/lib/Reader'
interface Dependencies {
logger: { log: (message: string) => void }
env: 'development' | 'production'
}
const c = ({ logger, env }: Dependencies) => {
logger.log(`[${env}] calling c function`)
return 1
}
const b = R.reader.map(c, n => n * 2)
const a = R.reader.map(b, n => n + 1)
// a, b and c types are the same: Reader<Dependencies, number>
const logger = { log: (message: string) => console.log(message) }
const deps: Dependencies = { logger, env: 'development' }
const result = a(deps)
assert(result === 3)
⚠️ NOTE: if you are using fp-ts v2.8.0 or more, you should import fp-ts modules without the /lib part. For example, here we would have import * as R from 'fp-ts/Reader'.
Now, a and b have no knowledge about the dependencies necessary to make c work, which is what we are looking for.
However, we still have some boilerplate due to Reader: the R.reader.map(ma, X) is common to both a and b and is
not related to their main logic: (respectively) adding 1 to a given number and doubling a given number.
Let's see if we can isolate the logic of a and b while keeping the advantages of Reader:
import * as R from 'fp-ts/lib/Reader'
import { pipe } from 'fp-ts/lib/pipeable'
interface Dependencies {
logger: { log: (message: string) => void }
env: 'development' | 'production'
}
const c = ({ logger, env }: Dependencies) => {
logger.log(`[${env}] calling c function`)
return 1
}
const b = (n: number) => n * 2
const a = (n: number) => n + 1
const logger = { log: (message: string) => console.log(message) }
const deps: Dependencies = { logger, env: 'development' }
const result = pipe(
R.of(a),
R.ap(pipe(
R.of(b),
R.ap(c)
))
)(deps)
assert(result === 3)
This is what's happening when using pipe:
-
First, we lift the a function into a Reader using R.of:
R.of((n: number) => number) -> Reader<Dependencies, (n: number) => number>
-
Then, inside the Reader context, we call a with the result of calling b using R.ap:
R.ap(Reader<Dependencies, number)(Reader<Dependencies, (n: number) => number>) -> Reader<Dependencies, number>
-
To get the result of b, we do the following:
-
Lift the b function into a Reader using R.of:
R.of((n: number) => number) -> Reader<Dependencies, (n: number) => number>
-
Inside the Reader context, call b with the result of calling c using R.ap:
R.ap(Reader<Dependencies, number)(Reader<Dependencies, (n: number) => number>) -> Reader<Dependencies, number>
So far we didn't execute anything, but we prepared the execution. By using pipe, we basically created this
function: deps => a(b(c(deps))). Calling pipe(...)(deps) is the same as calling (deps => a(b(c(deps))))(deps),
which runs the reader that triggers the execution of the program.
The result const could've been written the following way:
const result = pipe(
c,
R.map(b),
R.map(a)
)(deps)
This is actually the piped version of (deps => a(b(c(deps))))(deps).
Do note that using the Reader monad in this example, the order of execution is inverted: previously, when calling a(deps)
we were first calling a, then b and finally c. Using Reader we are actually calling c first in order to provide the result to b, then calling b to ultimately provide the result to a.
If you wish to preserve the original order of execution though, I don't think it's possible to use composition tools such as pipe,
but you can still use Reader to abstract c dependencies:
import * as R from 'fp-ts/lib/Reader'
interface Dependencies {
logger: { log: (message: string) => void }
env: 'development' | 'production'
}
const c = ({ logger, env }: Dependencies) => {
console.log('third')
logger.log(`[${env}] calling c function`)
return env === 'development' ? 1 : 0
}
const b = () => { // `b` is still unaware of `c` dependencies
console.log('second')
return R.reader.map(c, _ => _ * 2)
}
const a = () => { // `a` is still unaware of `c` dependencies
console.log('first')
const mustRunHeavyComputation = true
return mustRunHeavyComputation
? R.reader.map(b(), _ => _ + 1)
: R.reader.of(-1)
}
const logger = { log: (message: string) => console.log(message) }
const deps: Dependencies = { logger, env: 'development' }
// Logs 'first', then 'second' and finally 'third' messages, in that order
const result = a()(deps)
assert(result === 3)
Thanks a lot!