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!