It would be great to have some minimal example usage of Reader monad. As far as I understand, it's purpose is to avoid threading arguments through multiple functions in order only to get them where they belong (at the bottom of calls). It would be nice if there's an example of how to refactor e.g. such a code using Reader:
interface Config {
// ...
logLevel: number;
// ...
}
function foo(config: Config) {
// ...
return bar(config) // `bar` doesn't really needs it, it only passes it further
}
function bar(config: Config) {
// ...
baz(config)
// ...
}
function baz(config: Config) {
// ...
if(config.logLevel > 5) writeToLogFile("Error occured in baz")
// ...
}
As far as I understand, it's purpose is to avoid threading arguments through multiple functions in order only to get them where they belong (at the bottom of calls).
Thats the high level benefit. I would describe it like you can use a function without calling it or a Reader is just a monad for all functions of type (a: A) => B. The later one is important to understand then you see Readers everywhere.
Here a simple example. Please not that ReaderTaskEither<R, E, A> is just an alias for Reader<R, TaskEither<E, A>>.
import * as Rr from 'fp-ts/lib/Reader'
import * as RTE from 'fp-ts/lib/ReaderTaskEither'
import * as TE from 'fp-ts/lib/TaskEither'
import * as b from 'fp-ts/lib/boolean'
import { pipe } from 'fp-ts/lib/pipeable'
interface Config {
logLevel: number
}
const logLevel: Rr.Reader<Config, number> = config => config.logLevel
declare const foo: RTE.ReaderTaskEither<Config, Error, void>
const bar: RTE.ReaderTaskEither<Config, Error, void> = pipe(
logLevel,
Rr.map(logLevel =>
pipe(
logLevel > 5,
b.fold(
() => TE.right(undefined),
() => TE.rightIO(() => console.log('Some side effect'))
)
)
)
)
const main: RTE.ReaderTaskEither<Config, Error, void> = pipe(
foo,
RTE.chain(() => bar)
)
RTE.run(main, {
logLevel: 10
})
@mlegenhausen thanks. Hmmm, all the ReaderTaskEither stuff in this example is a bit overwhelming though. Could you please simplify it a bit more, to keep only the stuff essential for Reader use case? Perhaps some analogy to React Context (I assume, they kind of serve the same purpose - avoiding passing arguments multiple levels in function/componenst tree) would be helpful too (I've seen ask/asks functions in fp-ts and I have really no idea on how to use them - no arguments?). Thanks in advance :smile:
@vicrac @mlegenhausen what about:
The purpose of the Reader monad is to avoid threading arguments through multiple functions in order to only get them where they are needed.
One of the ideas presented here is to use the Reader monad for dependency injection.
The first thing you need to know is that the type Reader<R, A> represents a function (r: R) => A
interface Reader<R, A> {
(r: R): A
}
where R represents an "environment" needed for the computation (we can "read" from it) and A is the result.
Let's say we have the following piece of code
const f = (b: boolean): string => (b ? 'true' : 'false')
const g = (n: number): string => f(n > 2)
const h = (s: string): string => g(s.length + 1)
console.log(h('foo')) // 'true'
What if we want to internationalise f? Well, we could add an additional parameter
interface Dependencies {
i18n: {
true: string
false: string
}
}
const f = (b: boolean, deps: Dependencies): string => (b ? deps.i18n.true : deps.i18n.false)
Now we have a problem though, g doesn't compile anymore
const g = (n: number): string => f(n > 2) // error: An argument for 'deps' was not provided
We must add an additional parameter to g as well
const g = (n: number, deps: Dependencies): string => f(n > 2, deps) // ok
We haven't finished yet, now it's h that doesn't compile, we must add an additional parameter to h as well
const h = (s: string, deps: Dependencies): string => g(s.length + 1, deps)
finally we can run h by providing an actual instance of the Dependencies interface
const instance: Dependencies = {
i18n: {
true: 'vero',
false: 'falso'
}
}
console.log(h('foo', instance)) // 'vero'
As you can see, h and g must have knowledge about f dependencies despite not using them.
Can we improve this part? Yes we can, we can move Dependencies from the parameters list to the return type.
ReaderLet's start by rewriting our functions, putting the deps parameter alone
const f = (b: boolean): ((deps: Dependencies) => string) => deps => (b ? deps.i18n.true : deps.i18n.false)
const g = (n: number): ((deps: Dependencies) => string) => f(n > 2)
const h = (s: string): ((deps: Dependencies) => string) => g(s.length + 1)
Note that (deps: Dependencies) => string is just Reader<Dependencies, string>
import { Reader } from 'fp-ts/lib/Reader'
const f = (b: boolean): Reader<Dependencies, string> => deps => (b ? deps.i18n.true : deps.i18n.false)
const g = (n: number): Reader<Dependencies, string> => f(n > 2)
const h = (s: string): Reader<Dependencies, string> => g(s.length + 1)
console.log(h('foo')(instance)) // 'vero'
askWhat if we want to also inject the lower bound (2 in our example) in g? Let's add a new field to Dependencies first
export interface Dependencies {
i18n: {
true: string
false: string
}
lowerBound: number
}
const instance: Dependencies = {
i18n: {
true: 'vero',
false: 'falso'
},
lowerBound: 2
}
Now we can read lowerBound from the environment using ask
import { pipe } from 'fp-ts/lib/pipeable'
import { ask, chain, Reader } from 'fp-ts/lib/Reader'
const g = (n: number): Reader<Dependencies, string> =>
pipe(
ask<Dependencies>(),
chain(deps => f(n > deps.lowerBound))
)
console.log(h('foo')(instance)) // 'vero'
console.log(h('foo')({ ...instance, lowerBound: 4 })) // 'falso'
@gcanti Thanks for great explanation :smile: I'd still have one thing confusing me: what does really ask do? If I wrote it this way:
const g = (n: number) => (deps: Dependencies) => pipe(
deps,
chain(deps => fn(n > deps.lowerBound))
)
wouldn't it be exactly the same? Isn't ask just function returning identity function?
@vicrac it doesn't compile
Argument of type 'Dependencies' is not assignable to parameter of type 'Reader
'
So ask is merely a wrapper for proper type inference?
No, your g doesn't compile because the types don't align.
You can rewrite g as
// this compiles v-- no chain here
const g = (n: number) => (deps: Dependencies) => pipe(deps, f(n > deps.lowerBound))
I mean, that's fine, however if you come from this g
// v-- signature --------------------------v
const g = (n: number): Reader<Dependencies, string> => f(n > 2)
and at some point you want to access the environment, you may want to tweak only the implementation rather than the signature, so ask helps here
// v-- signature stay the same ------------v
const g = (n: number): Reader<Dependencies, string> =>
// while the implementation changes
pipe(
ask<Dependencies>(),
chain(deps => f(n > deps.lowerBound))
)
EDIT: in conclusion all the following gs are equivalent (so feel free to choose the style you prefer)
const g = (n: number) => (deps: Dependencies) => pipe(deps, f(n > deps.lowerBound))
const g = (n: number): Reader<Dependencies, string> => deps => pipe(deps, f(n > deps.lowerBound))
const g = (n: number): Reader<Dependencies, string> => deps => f(n > deps.lowerBound)(deps)
const g = (n: number): Reader<Dependencies, string> =>
pipe(
ask<Dependencies>(),
chain(deps => f(n > deps.lowerBound))
)
@gcanti Thanks, that makes a lot of thing clearer :smile: Another question though:
How does mapping on Reader fit into this example? I understand Reader<A, R> as a Monad for function A => R, so what are the monadic operations (map, chain, of) precisely responsible for? Could you describe it?
@vicrac not sure I understand the question, they are the usual monadic operations.
They are responsible for the same things they are responsible for in the case of, say, Either (or any other monad really): function composition.
Let's say you have the following snippet
import * as E from 'fp-ts/lib/Either'
import { pipe } from 'fp-ts/lib/pipeable'
declare function f(s: string): E.Either<Error, number>
declare function g(n: number): boolean
declare function h(b: boolean): E.Either<Error, Date>
// composing `f`, `g`, and `h` -------------v---------v-----------v
const result = pipe(E.right('foo'), E.chain(f), E.map(g), E.chain(h))
const pointFreeVersion = flow(f, E.map(g), E.chain(h))
and at some point you must refactor f to
import * as RE from 'fp-ts/lib/ReaderEither'
interface Dependencies {
foo: string
}
declare function f(s: string): RE.ReaderEither<Dependencies, Error, number>
result and pointFreeVersion must be refactored as well, fortunately you can use ReaderEither's monadic interface
// before
const result = pipe(E.right('foo'), E.chain(f), E.map(g), E.chain(h))
const pointFreeVersion = flow(f, E.map(g), E.chain(h))
// after
const result = pipe(
RE.right('foo'),
RE.chain(f),
RE.map(g),
RE.chain(b => RE.fromEither(h(b)))
)
const pointFreeVersion = flow(
f,
RE.map(g),
RE.chain(b => RE.fromEither(h(b)))
)
p.s.
As a curiosity, Reader's map is (the usual) function composition
import * as R from 'fp-ts/lib/Reader'
import { flow } from 'fp-ts/lib/function'
declare function len(s: string): number
declare function double(n: number): number
declare function gt2(n: number): boolean
const composition = flow(len, double, gt2)
// equivalent to
const composition = pipe(len, R.map(double), R.map(gt2))
btw this is basically boilerplate
RE.chain(b => RE.fromEither(h(b)))
we could add some helper, like
// ReaderEither.ts
export declare function chainEither<E, A, B>(
f: (a: A) => E.Either<E, B>
): <R>(ma: RE.ReaderEither<R, E, A>) => RE.ReaderEither<R, E, B>
so we can do
const pointFreeVersion = flow(
f,
RE.map(g),
RE.chainEither(h)
)
What do you think? I'll open a PR
I think that would be great :smile: Ok, it seems very clear to me now. I'll give it some try and dig around on how I can use it in my projects, and when I come to some conclusion, I'll open a PR adding docs to Reader as well :smile:
Btw. all the stuff you just wrote looks like a perfect base for an article on how it works and how to use Reader in fp-ts. Perhaps I'm missing something but I've not found anything about this e.g. on DEV blogs (thanks for great series on fp with fp-ts you've posted there) - it would be really cool to add it so anyone can read it, without digging in Github issues :rocket:
@vicrac yeah, I'll publish my first comment as a blog post as it is (too many things to do..). Perhaps I'll find the time to add some details in the next few weeks
Most helpful comment
@vicrac @mlegenhausen what about:
The purpose of the
Readermonad is to avoid threading arguments through multiple functions in order to only get them where they are needed.One of the ideas presented here is to use the
Readermonad for dependency injection.The first thing you need to know is that the type
Reader<R, A>represents a function(r: R) => Awhere
Rrepresents an "environment" needed for the computation (we can "read" from it) andAis the result.Example
Let's say we have the following piece of code
What if we want to internationalise
f? Well, we could add an additional parameterNow we have a problem though,
gdoesn't compile anymoreWe must add an additional parameter to
gas wellWe haven't finished yet, now it's
hthat doesn't compile, we must add an additional parameter tohas wellfinally we can run
hby providing an actual instance of theDependenciesinterfaceAs you can see,
handgmust have knowledge aboutfdependencies despite not using them.Can we improve this part? Yes we can, we can move
Dependenciesfrom the parameters list to the return type.ReaderLet's start by rewriting our functions, putting the
depsparameter aloneNote that
(deps: Dependencies) => stringis justReader<Dependencies, string>askWhat if we want to also inject the lower bound (
2in our example) ing? Let's add a new field toDependenciesfirstNow we can read
lowerBoundfrom the environment usingask