From 0 to Reader
Vincenzo Chianese
Posted on September 18, 2020
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.
Posted on September 18, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.