DEV Community

loading...

From 0 to Reader

Vincenzo Chianese
Vincenzo Chianese is an Italian Software Developer focused on WebAPIs and User Interfaces. Currently doing APIs @SentinelOne
Updated on ・5 min read

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

Enter fullscreen mode Exit fullscreen mode

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");
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
};
Enter fullscreen mode Exit fullscreen mode

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");
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

So that we change the way we use the function:

- const promise = userExists(user, logger);
+ const promise = userExists(user)(logger);
Enter fullscreen mode Exit fullscreen mode

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");
      }
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

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>>;
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

So I can make the following replacement:

- declare function userExists(user: string): Reader<Logger, Promise<boolean>>;
+ declare function userExists(user: string): ReaderPromise<Logger, boolean>;
Enter fullscreen mode Exit fullscreen mode

…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))
Enter fullscreen mode Exit fullscreen mode

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");
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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");
    })
  );
}
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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");
      }
    })
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (0)