DEV Community

Attila Večerek
Attila Večerek

Posted on

Reader monads

At the end of the previous post, there was a code example that could be improved in several ways. One of them was using dependency injection to improve its testability. In this post, we learn how to do just that using the reader monads.

The reader monad represents a computation that can read values from an environment, or context, and return a value. The Reader interface is declared the following way[1]:

interface Reader<R, A> {
  (r: R): A
}
Enter fullscreen mode Exit fullscreen mode

R represents the environment and A is the return value. In this post, we use the following terms to refer to R: "R type", "environment", "dependencies".

Similar to how dependency injection is used in object-oriented programming, the Reader monad serves as a mechanism for managing and accessing dependencies in functional programming. It abstracts away the manual handling of dependencies by implicitly threading them through the computation. Let's demonstrate this on a small example.

import { pipe } from "fp-ts/lib/function.js";

interface Logger {
  debug: (msg: string) => void;
}

const logger: Logger = {
  debug: (msg) => console.debug(msg),
};

const addWithLogging = (a: number) => ({ debug }: Logger) => (b: number): number => {
  debug(`Adding ${b} to ${a}`);

  return a + b;
}

const res = pipe(
  39,
  addWithLogging(1)(logger),
  addWithLogging(2)(logger),
);

console.log({ res });
Enter fullscreen mode Exit fullscreen mode

Notice the code duplication inside the pipe and how we manually pass down the logger to both calls of addWithLogging. This can be solved by the adoption of the Reader monad and its functions like so:

import { reader } from "fp-ts";
import { pipe } from "fp-ts/lib/function.js";

interface Logger {
  debug: (msg: string) => void;
}

const logger: Logger = {
  debug: (msg) => console.debug(msg),
};

const addWithLogging = (a: number) => (b: number): reader.Reader<Logger, number> => ({ debug }) => {
  debug(`Adding ${b} to ${a}`);

  return a + b;
}

const res = pipe(
  39,
  addWithLogging(1),
  reader.chain(addWithLogging(2)),
)(logger);

console.log({ res });
Enter fullscreen mode Exit fullscreen mode

Notice how we rearranged the order of arguments in addWithLogging to help us make the function more composable using reader.chain. With this, we can pass the logger to the computation in a single place only.

Types of Reader monads

Depending on the type of computation, there are different "flavors" of the Reader monad:

  • Reader: represents a synchronous operation that depends on a certain environment.
  • ReaderTask: represents an asynchronous operation that does not fail and depends on a certain environment. Syntactic sugar over Reader<R, Task<A>>. Basically, it is a Task with a declared dependency.
  • ReaderTaskEither: represents an asynchronous operation that can fail and depends on a certain environment. Syntactic sugar over Reader<R, TaskEither<E, A>>. Basically, it is a TaskEither with a declared dependency.
  • etc.

Conversions

It is possible to convert one Reader monad to another. The following table shows how to perform a couple of such conversions:

From To Converter
Task ReaderTask readerTask.fromTask
Reader ReaderTask readerTask.fromReader
Option ReaderTaskEither readerTaskEither.fromOption
Either ReaderTaskEither readerTaskEither.fromEither
Task ReaderTaskEither readerTaskEither.fromTask
TaskEither ReaderTaskEither readerTaskEither.fromTaskEither
Reader ReaderTaskEither readerTaskEither.fromReader

Reader-specific functions

The family of Reader monads implements a couple of functions that no other monad does. In this section, we take a brief look at some of them.

asks

reader.asks lets us tap into the environment and describe (not execute) a computation. It is defined as2:

import { reader } from "fp-ts";

export declare const asks: <R, A>(
  f: (r: R) => A
  ) => reader.Reader<R, A>;
Enter fullscreen mode Exit fullscreen mode

asks basically takes a function of Reader<R, A> and returns a Reader<R, A>. It may seem as if it returned itself. However, in practice, we may use this to tap into the environment, perform a computation, and return the result as a reader. This becomes particularly useful in combination with the Do notation and binding values.

The Do notation is basically just a pipe that starts with reader.Do which merely initializes an empty object for us to be the target of the bind operations that follow. Several monads implement the Do notation, it is not specific to the Reader monad.

import { random as _random, reader } from "fp-ts";
import { pipe } from "fp-ts/lib/function.js";

interface Random {
  next: () => number;
}

interface Env {
  random: Random;
}

const nextRandom: reader.Reader<Env, number> = ({
  random
}) => random.next();

export const chanceButLower: reader.Reader<Env, number> = pipe(
  reader.Do, // returns reader.Reader<unknown, {}>
  reader.bind("a", () => reader.asks(nextRandom)),
  reader.bind("b", () => reader.asks(nextRandom)),
  reader.map(({ a, b }) => a * b)
);

// Example usage
console.log({
  result: chanceButLower({ random: { next: _random.random } }),
});
Enter fullscreen mode Exit fullscreen mode

In the above example, the chanceButLower function depends on an environment containing a random number generator service. It generates two random numbers between 0 and 1, multiplies them, and returns the result.

local

Normally, we can only "read" the dependencies from within a reader. However, sometimes we may run into situations where we need to modify the R value before executing a chained computation. An example situation is when we want to compose two readers that have completely different dependencies. This is where reader.local comes into play. It is defined as3:

import type { reader } from "fp-ts";

export declare const local: <R2, R1>(
  f: (r2: R2) => R1
) => <A>(ma: reader.Reader<R1, A>) => reader.Reader<R2, A>;
Enter fullscreen mode Exit fullscreen mode

It takes two arguments: (1) a function that maps one R value to another, and (2) a reader of the R value returned by the first argument. The function itself returns a reader of the initial R value. The easiest way to think about this function is that it modifies the environment (context) for the local execution of its second argument. The ability of changing the environment for local function executions can help us compose readers of different R types.

import { random as _random, reader } from "fp-ts";
import { flow, pipe } from "fp-ts/lib/function.js";

interface Random {
  next: () => number;
}

const chanceButLower: reader.Reader<Random, number> = ({
  next
}) => next() * next();

type Result = "WIN" | "LOSE";

interface SlotMachine {
  result: (chance: number) => Result;
}

const predict = (
  chance: number
): reader.Reader<SlotMachine, Result> => ({
  result
}) => result(chance);

interface Deps {
  random: Random;
  slotMachine: SlotMachine;
}

const tryMyLuck: reader.Reader<Deps, Result> = pipe(
  chanceButLower,
  reader.local((deps: Deps) => deps.random),
  reader.chain(
    flow(
      predict,
      reader.local((deps: Deps) => deps.slotMachine)
    )
  )
)

// Example usage
console.log({
  result: tryMyLuck({
    random: { next: _random.random },
    slotMachine: { result: (chance) => chance >= 0.99 ? "WIN" : "LOSE" }
  })
});
Enter fullscreen mode Exit fullscreen mode

In the above example, we have two functions that each depend on a different R type. The chanceButLower function depends on a Random service, while the predict function does so on a SlotMachine service. The tryMyLuck function composes these two functions by using reader.local to map the environment for each function accordingly. This way, tryMyLuck depends on a combined environment of the two composed functions.

However, in this particular case, we can completely eliminate the need to use reader.local, and make the composition significantly simpler. To achieve that, we need to adopt a simple rule: all R types representing dependencies must be interface types, even if they contain just a single dependency.

import { random as _random, reader } from "fp-ts";
import { pipe } from "fp-ts/lib/function.js";

interface Random {
  next: () => number;
}

const chanceButLower: reader.Reader<{ random: Random }, number> = ({
  random
}) => random.next() * random.next();

type Result = "WIN" | "LOSE";

interface SlotMachine {
  result: (chance: number) => Result;
}

const predict = (
  chance: number
): reader.Reader<{ slotMachine: SlotMachine }, Result> => ({
  slotMachine
}) => slotMachine.result(chance);

interface Deps {
  random: Random;
  slotMachine: SlotMachine;
}

const tryMyLuck: reader.Reader<Deps, Result> = pipe(
  chanceButLower,
  reader.chainW(predict)
)

// Example usage
console.log({
  result: tryMyLuck({
    random: { next: _random.random },
    slotMachine: { result: (chance) => chance >= 0.99 ? "WIN" : "LOSE" }
  })
});
Enter fullscreen mode Exit fullscreen mode

Notice how much simpler the tryMyLuck function becomes compared to the previous code example. Because of reader's chainW the first function's environment is widened to the final (combined) environment.

As long as we follow the "R type convention" for dependencies, and use the same key to refer to the same dependency across the project, functions returning a Reader become trivial to compose.

Practical code example

Now that we know how to manage dependencies using the Reader monad, we are ready to fix the shortcomings of the example code from the previous post. Let's start by updating the Project domain file.

// domains/project.ts

import type { either, readerTaskEither } from "fp-ts";
import type * as db from "./db.js";
import type * as kafka from "./kafka.js";

export interface Project {
  id: string;
  name: string;
  description: string;
  organizationId: string;
}

export type ProjectInput = Pick<
  Project,
  "name" | "description" | "organizationId"
>;

export class ParseInputError {
  readonly _tag = "ParseInputError";
  constructor(readonly error: Error) {}
}

export class ProjectNameUnavailableError {
  readonly _tag = "ProjectNameUnavailableError";
  constructor(readonly error: Error) {}
}

export type ValidateAvailabilityError =
  | ProjectNameUnavailableError
  | db.QueryError;

export class ProjectLimitReachedError {
  readonly _tag = "ProjectLimitReachedError";
  constructor(readonly error: Error) {}
}

export type EnforceLimitError = ProjectLimitReachedError | db.QueryError;

export class MessageEncodingError {
  readonly _tag = "MessageEncodingError";
  constructor(readonly error: Error) {}
}

export type EmitEntityError = MessageEncodingError | kafka.ProducerError;

/**
 * A synchronous operation that accepts an unknown object
 * and parses it. The result is an `Either`.
 */
export type ParseInput = (
  input: unknown
) => either.Either<ParseInputError, ProjectInput>;
export declare const parseInput: ParseInput;

export interface DbEnv {
  db: db.Service
}

/**
 * A function that accepts an object representing
 * the input data of a project and returns a ReaderTaskEither
 * describing an asynchronous operation which queries
 * the database to check whether the project name
 * is still available. Project names across an organization
 * must be unique. This operation may fail due to different
 * reasons, such as network errors, database connection
 * errors, SQL syntax errors, etc. The database client
 * is the only dependency of this function.
 */
export type ValidateAvailability = (
  input: ProjectInput
) => readerTaskEither.ReaderTaskEither<DbEnv, ValidateAvailabilityError, void>;
export declare const validateAvailability: ValidateAvailability;

/**
 * A task describing an asynchronous operation that
 * queries the database for the number of existing
 * projects for a given organization. There is a
 * product limit for how many projects can be created
 * by an organization. This operation fails if the limit
 * is reached or any other network or database error occurs.
 * The database client is the only dependency of this function.
 */
export type EnforceLimit = readerTaskEither.ReaderTaskEither<DbEnv, EnforceLimitError, void>;
export declare const enforceLimit: EnforceLimit;

/**
 * A function that accepts a project object and returns
 * a task describing an asynchronous operation that
 * persists this object in the database. This operation
 * fails if any network or database error occurs.
 * The database client is the only dependency of this function.
 */
export type Create = (
  project: Project
) => readerTaskEither.ReaderTaskEither<DbEnv, db.QueryError, void>;
export declare const create: Create;

/**
 * A function that accepts a project object and returns
 * a task describing an asynchronous operation that encodes
 * this object and produces a Kafka message. This operation
 * fails if the encoding fails, or any other network or broker
 * error occurs. The database client is the only dependency
 * of this function.
 */
export type EmitEntity = (
  project: Project
) => readerTaskEither.ReaderTaskEither<DbEnv, EmitEntityError, void>;
export declare const emitEntity: EmitEntity;
Enter fullscreen mode Exit fullscreen mode

As we can see, all we did was we replaced taskEither.TaskEither with readerTaskEither.ReaderTaskEither, declared a type for the database dependency, and referenced it from all the necessary places. Now, we're ready to update the Project repository file containing the composition of the domain functions.

// repositories/project.ts

import { readerTaskEither } from "fp-ts";
import { flow } from "fp-ts/lib/function.js";
import { ulid } from "ulid";
import * as project from "../domains/project.js";

export const create = flow(
  project.parseInput,
  readerTaskEither.fromEither,
  readerTaskEither.chainFirstW(project.validateAvailability),
  readerTaskEither.chainFirstW(() => project.enforceLimit),
  readerTaskEither.bind("id", () => readerTaskEither.right(ulid())),
  readerTaskEither.chainFirstW(project.create),
  readerTaskEither.chainFirstW(project.emitEntity)
);
Enter fullscreen mode Exit fullscreen mode

As we can see, the update here was just as simple as the one above if not even simpler.

Let's take a look at another shortcoming, the lack of transaction control. To address that, we need to implement a withTransaction function that takes a computation and makes sure that it starts a transaction before the computation is executed and that it commits and rolls back the transaction according to the result of the computation. After the implementation, this is how the file implementing our database service looks like:

import { either, readerTaskEither, taskEither } from "fp-ts";
import { constVoid, pipe } from "fp-ts/lib/function.js";

export class QueryError {
  readonly _tag = "QueryError";
  constructor(readonly error: Error) {}
}

export interface Service {
  query: (sql: string) => (values: unknown[]) => taskEither.TaskEither<QueryError, unknown>;
}

export class StartTransactionFailedError {
  readonly _tag = "StartTransactionFailedError";
  constructor(readonly error: Error) {}
}

export class RollbackFailedError {
  readonly _tag = "RollbackFailedError";
  constructor(readonly error: Error) {}
}

export class CommitFailedError {
  readonly _tag = "RollbackFailedError";
  constructor(readonly error: Error) {}
}

export type WithTransactionError = CommitFailedError | QueryError | RollbackFailedError | StartTransactionFailedError;

export interface WithTransaction {
  <R, E, A>(
    use: readerTaskEither.ReaderTaskEither<R, E, A>
  ): readerTaskEither.ReaderTaskEither<R & { db: Service }, E | WithTransactionError, A>
}
export const withTransaction: WithTransaction = (use) => (env) => taskEither.bracketW(
  pipe(
    [],
    env.db.query("START TRANSACTION"),
    taskEither.mapLeft(({ error }) => new StartTransactionFailedError(error))
  ),
  () => use(env),
  (_, res) =>
    pipe(
      res,
      either.match(
        () =>
          pipe(
            [],
            env.db.query("ROLLBACK"),
            taskEither.mapLeft(({ error }) => new RollbackFailedError(error)),
            taskEither.map(constVoid)
          ),
        () =>
          pipe(
            [],
            env.db.query("COMMIT"),
            taskEither.mapLeft(({ error }) => new CommitFailedError(error)),
            taskEither.map(constVoid)
          )
      )
    )
);
Enter fullscreen mode Exit fullscreen mode

The withTransaction function takes a single argument (use) representing an async computation that can fail and depends on a certain environment. The return value of this function is another async computation that can also fail and depends on a combined environment of the use function and its own environment ({ db: Service }). It is implemented using taskEither.bracket. We can think of bracket as a resource manager that works in three steps:

  1. It acquires a resource. We simply execute a START TRANSACTION query.
  2. It performs a computation, optionally by using the acquired resource. In this particular case, we throw the value of the resource away.
  3. It releases the resource. In this step, bracket provides the acquired resource and the result of the computation from the previous step to the caller. We inspect the result:
    • in the case of a failure, we execute the ROLLBACK query,
    • otherwise, we execute the COMMIT query.

To wrap our composed project.create function in a transaction, all we need to do is to call withTransaction as the last step of the pipeline like so:

// repositories/project.ts

import { readerTaskEither } from "fp-ts";
import { flow } from "fp-ts/lib/function.js";
import { ulid } from "ulid";
import * as project from "../domains/project.js";
import * as db from "../db.js";

export const create = flow(
  project.parseInput,
  readerTaskEither.fromEither,
  readerTaskEither.chainFirstW(project.validateAvailability),
  readerTaskEither.chainFirstW(() => project.enforceLimit),
  readerTaskEither.bind("id", () => readerTaskEither.right(ulid())),
  readerTaskEither.chainFirstW(project.create),
  readerTaskEither.chainFirstW(project.emitEntity),
  db.withTransaction
);
Enter fullscreen mode Exit fullscreen mode

Just like in the previous post, the type of the create function gets widened again. This time by the additional db.WithTransactionError error type:

import type { readerTaskEither } from "fp-ts";
import type * as project from "../domains/project.js";
import type * as db from "../db.js";

export interface Create {
  (input: unknown): readerTaskEither.ReaderTaskEither<
    project.DbEnv,
    | db.WithTransactionError
    | db.QueryError
    | project.ParseInputError
    | project.ProjectNameUnavailableError
    | project.ProjectLimitReachedError
    | project.EmitEntityError,
    project.Project
  >;
}
Enter fullscreen mode Exit fullscreen mode

Wrap-up

  • The Reader monad serves as a mechanism for managing and accessing dependencies.
  • reader.asks allows us to access the dependencies, perform a computation, and return its result as another reader of the same dependencies.
  • reader.local allows us to compose readers of different dependencies.
  • Reader monads compose much easier when we adopt the convention of representing dependencies through an interface type (even if the reader only declares a single dependency) and we give our dependencies a consistent key across the whole project.
  • Depending on the result type, there can be different "flavors" of the Reader monad, for example ReaderEither for readers returning an Either.
  • It is easy to convert between instances of different "flavors" of reader monads.
  • It is easier to reason about programs using Reader monads because it is immediately clear what dependencies must be satisfied by the caller.
  • It is easier to test code that uses Reader monads the same way it is easier to test object-oriented code that uses dependency injection.

The next post of this series is centered around runtime type-systems and how to use them to build programs with end-to-end type safety.

Extra resources

Top comments (0)