Thanks to visit codestin.com
Credit goes to dev.to

DEV Community

Vincenzo Chianese
Vincenzo Chianese

Posted on • Edited on

From 0 to Reader

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>;

Codestin Search App Codestin Search App

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");
  }
}
Codestin Search App Codestin Search App

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,
};
Codestin Search App Codestin Search App

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");
    }
  }
}
Codestin Search App Codestin Search App

Two things aren't really cool with such approach:

  1. I have to pass the logger in every single function that needs this — every function must be aware of the new dependency
  2. 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>;
Codestin Search App Codestin Search App

So that we change the way we use the function:

- const promise = userExists(user, logger);
+ const promise = userExists(user)(logger);
Codestin Search App Codestin Search App

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");
      }
    }
  };
}
Codestin Search App Codestin Search App

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>>;
Codestin Search App Codestin Search App

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>
}
Codestin Search App Codestin Search App

So I can make the following replacement:

- declare function userExists(user: string): Reader<Logger, Promise<boolean>>;
+ declare function userExists(user: string): ReaderPromise<Logger, boolean>;
Codestin Search App Codestin Search App

…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))
Codestin Search App Codestin Search App

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");
    }
  });
}
Codestin Search App Codestin Search App

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))

Codestin Search App Codestin Search App

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");
    })
  );
}
Codestin Search App Codestin Search App

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>;
Codestin Search App Codestin Search App

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");
      }
    })
  );
}
Codestin Search App Codestin Search App

What are the differences with the original, naive, solution?

  1. Functions are not aware of the dependency at all. You just chain them and inject the dependency once: const user = await createUser(details)(logger)()
  2. The logger is now a separate set of arguments, making really clear what is a dependency and what is a function argument
  3. You can reason about the result of the computation even though you haven't executed anything yet.

Top comments (0)