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
}
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 });
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 });
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 overReader<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 overReader<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>;
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 bind
ing 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 } }),
});
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>;
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" }
})
});
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" }
})
});
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;
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)
);
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)
)
)
)
);
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:
- It acquires a resource. We simply execute a
START TRANSACTION
query. - It performs a computation, optionally by using the acquired resource. In this particular case, we throw the value of the resource away.
- 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.
- in the case of a failure, we execute the
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
);
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
>;
}
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 anEither
. - 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.
Top comments (0)