The following notes come from an internal discussion I had with some coworkers with no pretension to be an accurate explanation of the Reader monad. Still, my teammates claimed they were helpful to understand the concept; so better put them online.
We'll start with a function whose job is to insert an user in a database:
type User = { username: string; age: number; }; declare function createUser( user: string, details: unknown ): Promise<User>;
Let's write some code to implement the function:
type User = { username: string; age: number; }; declare function userExists(user: string): Promise<boolean>; declare function createUserAccount( user: string ): Promise<boolean>; declare function runAutomaticTrigger( user: string ): Promise<boolean>; async function insertInDb(user: User): Promise<boolean> { const db = []; db.push(user); return runAutomaticTrigger(user.username); } async function createUser(details: User): Promise<User> { const isPresent = await userExists(details.username); if (isPresent) { const inserted = await insertInDb(details); if (inserted) { const accountCreated = await createUserAccount( details.username ); if (accountCreated) return details; else throw new Error("unable to create user account"); } else throw new Error("unable to insert user in Db"); } else { throw new Error("user already exists"); } }
Now let's say that somebody comes says we need to add logging with this object.
type Logger = { info: (msg: string) => undefined, debug: (msg: string) => undefined, warn: (msg: string) => undefined, error: (msg: string) => undefined, };
Additionally, let's put the constraint in place that the logger is not a singleton instance — thus it's an instance that needs to be carried around.
declare function userExists(user: string, l: Logger): Promise<boolean>; declare function createUserAccount(user: string, l: Logger): Promise<boolean>; declare function runAutomaticTrigger(user: string, l: Logger): Promise<boolean>; async function insertInDb(user: User, l: Logger): Promise<boolean> { const db = []; db.push(user); l.info("User inserted, running trigger"); return runAutomaticTrigger(user.username, l); } async function createUser(details: User): Promise<User> { const isPresent = await userExists(details.username, l); if (isPresent) { const inserted = await insertInDb(details, l); if (inserted) { const accountCreated = await createUserAccount(details.username, l); if (accountCreated) return details; else { throw new Error("unable to create user account"); } } else { throw new Error("unable to insert user in Db"); } } else { { throw new Error("user already exists"); } } }
Two things aren't really cool with such approach:
- I have to pass the logger in every single function that needs this — every function must be aware of the new dependency
- The logger is a dependency, not really a function argument.
To start fixing this, let's try to put the dependency elsewhere:
- declare function userExists(user: string, l: Logger): Promise<boolean>; + declare function userExists(user: string): (l: Logger) => Promise<boolean>;
So that we change the way we use the function:
- const promise = userExists(user, logger); + const promise = userExists(user)(logger);
The result is:
declare function userExists(user: string): (l: Logger) => Promise<boolean>; declare function createUserAccount( user: string ): (l: Logger) => Promise<boolean>; declare function runAutomaticTrigger( user: string ): (l: Logger) => Promise<boolean>; function insertInDb(user: User) { return (l: Logger) => { const db = []; db.push(user); return runAutomaticTrigger(user.username)(l); }; } async function createUser(details: User) { return async (l: Logger) => { const isPresent = await userExists(details.username)(l); if (isPresent) { const inserted = await insertInDb(details)(l); if (inserted) { const accountCreated = await createUserAccount(details.username)(l); if (accountCreated) return details; else { throw new Error("unable to create user account"); } } else { throw new Error("unable to insert user in Db"); } } else { { throw new Error("user already exists"); } } }; }
Let's now introduce a type to help us out to model this:
type Reader<R, A> = (r: R) => A;
And so we can now rewrite userExists
as:
- declare function userExists(user: string): (l: Logger) => Promise<boolean>; + declare function userExists(user: string): Reader<Logger, Promise<boolean>>;
Since TypeScript does not support HKT (but I still pray everyday that eventually it will), I am going to define a more specific type
interface ReaderPromise<R, A> { (r: R): Promise<A> }
So I can make the following replacement:
- declare function userExists(user: string): Reader<Logger, Promise<boolean>>; + declare function userExists(user: string): ReaderPromise<Logger, boolean>;
…and if I define an helper function called chain:
const chain = <R, A, B>(ma: ReaderPromise<R, A>, f: (a: A) => ReaderPromise<R, B>): ReaderPromise<R, B> => (r) => ma(r).then((a) => f(a)(r))
I can now rewrite the entire flow in such way:
function createUser(details: User): ReaderPromise<Logger, User> { return chain(userExists(details.username), (isPresent) => { if (isPresent) { return chain(insertInDb(details), (inserted) => { if (inserted) { return chain(createUserAccount(details.username), (accountCreated) => { if (accountCreated) { return (logger) => Promise.resolve(details); } else { throw new Error("unable to insert user in Db"); } }); } else { throw new Error("unable to create user account"); } }); } else { throw new Error("user already exists"); } }); }
but that ain't that cool, since we're nesting nesting and nesting. We need to move to the next level.
Let's rewrite chain to be curried…
- const chain = <R, A, B>(ma: ReaderPromise<R, A>, f: (a: A) => ReaderPromise<R, B>): ReaderPromise<R, B> => (r) => ma(r).then((a) => f(a)(r)) + const chain = <R, A, B>(f: (a: A) => ReaderPromise<R, B>) => (ma: ReaderPromise<R, A>): ReaderPromise<R, B> => (r) => ma(r).then((a) => f(a)(r))
Well what happens now is that I can use ANY implementation of the pipe operator (the one in lodash will do), and write the flow in this way:
function createUser2(details: User): ReaderPromise<Logger, User> { return pipe( userExists(details.username), chain((isPresent) => { if (isPresent) return insertInDb(details); throw new Error("user already exists"); }), chain((inserted) => { if (inserted) return createUserAccount(details.username); throw new Error("unable to create user account"); }), chain((accountCreated) => { if (accountCreated) return DoSomething; throw new Error("unable to create user account"); }) ); }
I can introduce another abstraction called Task
type Task<T> = () => Promise<T>
and then, just for commodity
type ReaderTask<R, A> = Reader<R, Task<A>>
Then I can refactor this part a little bit:
- declare function userExists(user: string): Reader<Logger, Promise<boolean>>; + declare function userExists(user: string): ReaderTask<Logger, boolean>;
It turns out fp-ts already has a bunch of these defined, so I'm not going to bother using mines:
import * as R from "fp-ts/Reader"; import * as RT from "fp-ts/ReaderTask"; import { pipe } from "fp-ts/pipeable"; type User = { username: string; age: number; }; type Logger = { info: (msg: string) => void; debug: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void; }; declare function userExists(user: string): RT.ReaderTask<Logger, boolean>; declare function createUserAccount( user: string ): RT.ReaderTask<Logger, boolean>; declare function runAutomaticTrigger( user: string ): RT.ReaderTask<Logger, boolean>; function insertInDb(user: User): RT.ReaderTask<Logger, boolean> { const db = []; db.push(user); return runAutomaticTrigger(user.username); } function createUser(details: User): RT.ReaderTask<Logger, Promise<User>> { return pipe( RT.ask<Logger>(), RT.chain(l => userExists(details.username)), RT.chain(isPresent => { if (isPresent) { return insertInDb(details); } else { throw new Error("user already exists"); } }), RT.chain(inserted => { if (inserted) { return createUserAccount(details.username); } else { throw new Error("unable to create user account"); } }), RT.map(accountCreated => { if (accountCreated) { return Promise.resolve(details); } else { throw new Error("unable to insert user in Db"); } }) ); }
What are the differences with the original, naive, solution?
- Functions are not aware of the dependency at all. You just chain them and inject the dependency once:
const user = await createUser(details)(logger)()
- The logger is now a separate set of arguments, making really clear what is a dependency and what is a function argument
- You can reason about the result of the computation even though you haven't executed anything yet.
Top comments (0)