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)