TypeScript + fp-ts: ReaderTaskEither and React

ReaderTaskEither<R, E, A> and React

This article is intended to build up some intuitions about the ReaderTaskEither<R, E, A> type from fp-ts and then present some real-world usage guides for using it with React.

The code from this article is loosely based on some play-around/experimental code here: https://github.com/andywhite37/react-rte-experiment

I’d also recommend some similar posts about functional dependency injection - these articles are centered around F#, but the same concepts apply:

I’d also recommend the ZIO documentation and all the community articles about ZIO for more information, ideas, and techniques.

Finally, I’d also highly recommend looking into effect-ts, which is a super-modern, TypeScript-native implementation of these ideas, inspired by ZIO.

Overview

The first thing to note about ReaderTaskEither<R, E, A> is that it provides a ton of built-in functionality for doing all the common operations you’d typically want from an “effect type.”

The type

As a reminder, the type of RTE is the following:

type ReaderTaskEither<R, E, A> = Reader<R, TaskEither<E, A>>
                               = Reader<R, Task<Either<E, A>>>
                               = Reader<R, () => Promise<Either<E, A>>
                               = (r: R) => () => Promise<Either<E, A>>

The key thing to keep in mind is that the type of RTE is a function - not a static data structure like Option or Either. Because of this, the implementations of some of the functions like map may look or feel a little different than how they’re implemented for the data structure types.

One intuition for RTE is that it’s a “canned effect” that describes some operation that has some input(s), runs asynchronously and eventually completes with an error or success. You can combine and compose RTE effects that have different inputs, and that produce different errors or outputs. As you compose RTE effects, the input type will often “widen” (typically via &), and the output types will widen or be combined depending on the type of combinator you’re using. Eventually you’ll have a complete program or RTE effect that fully describes all the necessary inputs and all the possible outputs.

Constructors

A ReaderTaskEither<R, E, A> can be constructed (or created) in a variety of ways:

From a pure value of type A

If you have a “pure” value that you simply want to “lift” into a RTE, you can use the standard function of:

const myRTE1: ReaderTaskEither<unknown, never, number> = RTE.of(42) // explicit annotation
const myRTE2 = RTE.of(42) // ReaderTaskEither<unknown, unknown, number> - unfortunate inference

Note that the type is annotated here with unknown for the R type (because the computation has no external dependency), and with never as the E type because we want to explicitly state via the types that this effect has no possibility of failure. If you don’t annotate the type like this, TypeScript will infer the type as ReaderTaskEither<unknown, unknown, number>, which is unfortunate, because it infers unknown for E rather than never.

There is another constructor RTE.right that has better type inference than of:

const myRTE = RTE.right(42) // ReaderTaskEither<unknown, never, number> - better inference than of

From an error value of type E

Similar to RTE.right to put a value in the Right, use RTE.left to put a value in the Left of the Either.

const myRTE = RTE.left({ message: 'Fail!' }) // ReaderTaskEither<unknown, { message: string }, never>

Note that this type infers correctly, and the A type is set to never to correctly indicate that this RTE effect cannot be successful.

From a Task<A> or Task<E>

If you have a Task containing either an A value or an E value, you can “lift” it into a RTE using:

const myTaskA: Task<number> = () => Promise.resolve(42)

const myTaskE: Task<{ message: string }> = () =>
  Promise.resolve({ message: 'Fail' })

const myRTE = RTE.rightTask(myTaskA) // ReaderTaskEither<unknown, never, number>

const myRTE = RTE.leftTask(myTaskE) // ReaderTaskEither<unknown, { message: string }, never>

RTE.leftTask might seem a little strange, but remember that a Task does not have a way to express an error type E. The rightTask/leftTask constructors simply take the result of the Task (which should always be a resolved Promise) and put it into either the Right or Left channel of the RTE. If we are dealing with a Promise<A>/Task<A> that has the possibility of failure, we should be using a TaskEither<E, A>.

From a Promise<A>

Because RTE is backed by a Promise, it’s easy to lift a Promise<A> into an RTE, but you need to be aware of some pitfalls.

Since a Promise<A> doesn’t have a way of expressing a Reader-like “input” or dependency, we would expect for the RTE to have unknown as the R type. For the E type of the RTE, Promise<A> does not express a generic (or polymorphic) error type - it carries a “hidden” error type which manifests itself as an any. We don’t want to deal with any, so in order to convert a Promise<A> into a ReaderTaskEither<R, E, A>, we need to explicitly convert this any error type into our well-known E type ourselves. TaskEither provides a tryCatch function that serves this purpose.

const myTaskEitherGood: TE.TaskEither<
  { message: string },
  number
> = TE.tryCatch(
  () =>
    new Promise<number>((resolve, reject) => {
      setTimeout(() => {
        resolve(42)
      }, 100)
    }),
  (e: any) => ({ message: 'Oops' }), // Or do some runtime inspection of `e` to try to figure out what it is :(
)

const myTaskEitherBad: TE.TaskEither<{ message: string }, number> = TE.tryCatch(
  () =>
    new Promise<number>((resolve, reject) => {
      setTimeout(() => {
        reject('Yolo') // Promise can be rejected with any value
      }, 100)
    }),
  (e: any) => {
    return e === 'Yolo' ? { message: 'Fail' } : { message: 'I have no idea' }
  },
)

Any time you have to deal with a “raw” Promise<A>, you should wrap it in a TE.tryCatch like this, to ensure that if the Promise is rejected, the error will be properly converted into the E type, and lifted into the Either<E, A> in the Promise<Either<E, A>>. With TaskEither and ReaderTaskEither, we never want to be dealing with a rejected Promise under the hood, because that means we’ve not properly handled the errors, and it will likely go uncaught and crash the program. RTE doesn’t currently have it’s own built-in tryCatch function like this, but it could be easily added as a helper function, and would likely just use TE.tryCatch like above, with the addition of an unknown Reader param.

Another thing to be aware of is that you should always try to deal with “lazy” Promises (i.e. Task-like types), if possible, because once a Promise is constructed, it immediately starts executing. If you take an already-running Promise<A> and wrap it in a Task, TaskEither or ReaderTaskEither, you are not actually getting the purity guarantee of the lazy types. However, when dealing with JS libs, you are sometimes just given a Promise<A>, so there are cases where you might have to just wrap it. To avoid that, try to invoke the JS lib lazily (i.e. in an IO<A>), so you can keep the Promise lazy until you want to actually run your RTE.

One final pitfall of Task to be aware of is the “under-the-hood” failure that can happen.

const myTaskA: Task<number> = () => Promise.reject('Oops')

In this case, we are in trouble, because Task is not supposed to fail, but here, it has. This is something that you have to just be aware of when dealing with Promises. You should almost always use TaskEither and lift the Promise<A> into TaskEither<E, A> to explicitly deal with any unexpected errors from the Promise<A>. You should probably only use Task<A> directly if you know for a fact that the Promise<A> cannot fail.

Key takeaway: always wrap raw/unknown Promises in a TE.tryCatch or RTE.tryCatch. Only use a Task<A> if you know for sure the promise can never be rejected.

From other types of “lower-level” effect types

There are a host of other functions that can “lift” values of less-capable effect types into a ReaderTaskEither<R, E, A>, but we won’t go into detail on all of these. Basically the idea is if you have a type that is compatible with RTE (i.e. has the same or subset of its capabilities), there should be a way to lift it into an RTE. Just look at the types of the inputs and outputs of the function.

RTE.rightReader
RTE.leftReader
RTE.rightReaderTask
RTE.leftReaderTask
RTE.fromIOEither
RTE.fromReaderEither
RTE.rightIO
RTE.leftIO

map and Functor

RTE has a map function and a corresponding instance of the Functor typeclass, which means you convert the “output” value from one type to another using a pure function.

pipe(
  RTE.right(42)
  RTE.map((n: number) => n.toString()),
)

Just like any other Functor, you should not do any side-effects, throw errors, etc. when using map.

apply, Apply, and Applicative

RTE also has an apply function and corresponding Apply and Applicative instances, so we can compose “parallel” or “independent” RTE operations. To relate this to Promise<A>, it’s the same idea as Promise.all([...]). The parallel behavior is provided by the apply function and it’s corresponding Apply/Applicative instance, but we most commonly do parallel/independent operations using sequenceS and sequenceT. We’ll get more into how RTEs compose later after discussing the Reader part, but know that you can do sequenceS and sequenceT with RTE, just like you can with types like Option, Either, or RemoteData:

// 3 independent effects
// None of these have run yet - they just describe some operations we want to do
const myRTE1 = RTE.right(1)
const myRTE2 = RTE.right(2)
const myRTE3 = RTE.right(3)

// Create a new effect that combines the 3 effects together into a "final" object result
// Note: the effects still haven't run yet - the result is only computed when this outer
// RTE is run.
const myRTEAll: ReaderTaskEither<
  unknown,
  never,
  { value1: number; value2: number; value3: number }
> = sequenceS(RTE.readerTaskEither)({
  value1: myRTE1,
  value2: myRTE2,
  value3: myRTE3,
})

How the R and E types are handled here will be covered later, but note that similar to Promise.all, this operation takes 3 independent effects, and is able to combine them all together.

chain and Monad

RTE has a chain and corresponding Monad instance too, so you can compose RTE effects in a sequential order, just like what you can do with Option, Either, etc. The idea with chain is that you have the ability to chain RTE effects together so that the value produced by one can be the input to the next effectful computation, and so on. This is all done lazily via function composition, so none of the effects are actually run until you tell it to at the end. We’ll explore this more after seeing the Reader part. Below is a uselessly simple example, and will be expanded upon later.

pipe(
  RTE.of(42),
  RTE.chain(n => RTE.of(n * 2)),
  RTE.chain(n => RTE.of(n.toString())),
)

Other chain variants

When using a monad to chain computations, you have to “stay within the same monad” throughout your computation. I.e. if you’re doing a computation in Option, you have to normalize all your operations to deal in Option. This is often done by lifting other types of computations into the target monad, like if you have a computation that results in A | null, you lift that type into Option<A> using O.fromNullable in order to use Option-based combinators on it. The same is true about RTE; however, RTE is a multi-faceted monad in terms of capabilities (supports input, is async, can fail), and a lot of times you might need to do operations in less-capable monads. E.g. you might need to use a function like (x: unknown) => Either<E, A> to decode a value in the middle of an RTE computation. fp-ts provides some helper functions for “lifting” these lower-level types into RTE. These operations are named like chain{Monad}K or sometimes chain{Monad}KW, e.g. chainTaskEitherK or chainTaskEitherKW. For reference, if you’re chaining another RTE-based computation, you’d just use chain or chainW. The K stands for Kleisli - a Kleisli is simply a monadic (aka effectful) function of the form (a: A) => F<B> where F is any monadic type constructor. The W stands for “widen,” which means the function will automatically “widen” the reader input type using & and widen the error type using |.

For example:

pipe(
  RTE.right(42),
  RTE.chain(n => RTE.of(n * 2)), // Normal chain - chaining an RTE computation on an RTE
  RTE.chainTaskEitherK(n => TE.right(n * 3)), // Chain a TE-based computation on an RTE
  RTE.chainEitherK(n => E.right(n * 4)), // Chain an Either-based computation
)

When writing functions, try to use the correct monad in terms of capabilities, e.g. don’t use RTE on things that can be done with just Either. Follow the “principle of least power” - only depend on types that provide the minimum set of capabilities you need to do your work, and avoid writing functions in RTE just for the sake of convenience. fp-ts provides many helper functions for combining these types, so it’s valuable to review the full API and see how the different types can be combined.

alt and Alternative

One lesser-known way of composing effect types is the alt function. This function can be thought of as an “or else” operation - you run an effect, and if it happens to fail, you provide another effect to try, and so-on. The fallback effects are expressed lazily, so they will only run if needed.

pipe(
  RTE.left('Fail'),
  RTE.alt(() => RTE.left('Tried again and failed')),
  RTE.alt(() => RTE.right(42)),
  RTE.alt(() => RTE.left('This will never run')),
)

The Reader part

So far, we have not seen what the Reader and its R parameter are for. Reader is simply just a function (r: R) => A, but the R parameter gives you the ability to make your computation abstract based on some externally-provided dependency. This seems ridiculously simple, but if you think about many effect types that don’t have a Reader, the inputs to the computation have to be provided from some implicit environmental context or closure - you can’t express the inputs in the effect type itself. Reader lets us express that type and abstract on it. The R type can be whatever type you want, but in the real world, the common way using R is to make it an object containing a key for each individual dependency you need for your computation. Sometimes this concept is easier to explain with a realistic example.

Before we jump into examples, one key function to understand with RTE is RTE.ask (or the related RTE.asks). ReaderTaskEither<R, E, A> can be expressed as a function like this:

const rte = (r: boolean): TaskEither<string, number> =>
  r ? TE.right(42) : TE.left('oops')

Here we have explicitly separated out the Reader argument (r: boolean) so we can use it within the implementation. However, you can write an RTE without the explicit (r: R) argument like this:

const rte: ReaderTaskEither<boolean, string, number> = pipe(
  // How do we get `r`?
  RTE.of(42),
)

If you are using this way, it seems there’s no way to access the R value, since we don’t have the explicit argument for it. The ask function is the way you get the R - it basically is just an RTE combinator, that takes the Reader R input and passes it through to the Reader’s output channel, so you can get to it by chaining or mapping on a continuation function after it:

const rte: ReaderTaskEither<boolean, string, number> = pipe(
  RTE.ask<boolean>(),
  RTE.chainEitherK((r: boolean) => (r ? E.right(42) : E.left('oops'))),
)

RTE.asks is similar to ask, but it just additionally lets you perform a function on R before passing it along - this would typically be to extract a specific sub-value out of the overall R “environment” value.

Real-world example

Let’s say we want to write an application that can make an API call with fetch, and can interact with the browser’s localStorage. Dealing with fetch involves asynchronous side effects, and dealing with localStorage involves synchronous side effects. We would like to write our business logic so we are not directly coupled to the actual browser fetch and localStorage implementations, so that we can mock these things for our unit tests, and to give ourselves the opportunity to change the implementations more easily down the road.

Let’s start by writing interfaces for these “services,” so we can abstract the implementations:

HttpClient

// Error types
type HttpRequestError = {
  tag: 'httpRequestError'
  error: unknown
}

type HttpContentTypeError = {
  tag: 'httpContentTypeError'
  error: unknown
}

type HttpResponseStatusError = {
  tag: 'httpResponseStatusError'
  status: number
}

// Interface
interface HttpClient {
  request(
    input: RequestInfo,
    init?: RequestInit,
  ): TE.TaskEither<HttpRequestError, Response>
}

// RTE "module" interface - for composing with other dependencies
interface HttpClientEnv {
  httpClient: HttpClient
}

// "Live" implementation backed by `fetch`
const fetchHttpClient: HttpClient = {
  request: (input, init) =>
    TE.tryCatch(
      () => {
        return fetch(input, init)
      },
      (e: any) => ({
        tag: 'httpRequestError',
        error: e,
      }),
    ),
}

The HTTP client interface is modeled as a function of the type:

(input: RequestInit, init?: RequestInit) => TaskEither<HttpRequestError, Response>

You might notice that a type of this form looks like a Reader, and you are correct - we could model this as a ReaderTaskEither, but with the Reader pattern, there can sometimes be a subtle distinction between a functions “arguments” and its “dependencies,” which sometimes boils down to a judgement call. In simple terms, the Reader arg is typically for abstract services or dependencies that typically bubble to the top of the program where you compose together all your dependencies; whereas, the “arguments” are things that you are directly operating on in your function, or arguments that wouldn’t make sense to “bubble up” to the top of your program. Here, the input and init are treated as non-Reader arguments, because they will vary per call, and are not things that we’d want to bubble up from this level. In our case, we have no dependency at all, so the effect is modeled as just a TaskEither<E, A> - an async effect that can fail - basically a better Promise<A>

The other reason we’re not using Reader for HttpClient is that the implementation of this HttpClient interface is expected to be the “end of the line” in terms of dependencies - things will depend on this interface, and this interface is not going to depend on anything else.

The other thing to note here is that we’re modelling the E error type as a simple tagged wrapper around an unknown error emitted by fetch. You could do more runtime inspection of the fetch error and decompose this into more specific error types, but we’ll just use this for demonstration purposes - this need for runtime inspection is one of the main reasons we don’t want to deal with untyped errors - it’s impossible to know what you’re going to get at compile time. For the request function, we’re just going to use the RequestInfo, RequestInit, and Response types you’d normally use directly with fetch, but these could be abstracted more too if we wanted to. The fetchHttpClient is simply an implementation of our HttpClient interface, backed by the normal Web API fetch function, but wrapped in the safer TE.tryCatch.

localStorage

For localStorage, we want to abstract the side effects for testability, so we’ll model it as a simple wrapper interface like below. Here, we’re using IO<A> because these are synchronous effects that shouldn’t fail. If these operations could throw, we’d probably use IOEither<E, A> instead. You could also model this interface without IO<A>, and just make it directly effectful, but then the caller would likely have to wrap all the calls in IO<A> themselves to maintain purity. This is also sometimes a judgement call whether to impose effect types at the library level or application level, but we’ll err on the side of enforced purity.

// Interface
export interface Storage {
  getItem(key: string): IO.IO<O.Option<string>>
  setItem(key: string, value: string): IO.IO<void>
}

// RTE "module" interface - for composing with other dependencies
interface StorageEnv {
  storage: Storage
}

// Implementation with DOM localStorage
export const domStorage: Storage = {
  getItem: (key: string) => () => O.fromNullable(localStorage.getItem(key)),
  setItem: (key: string, value: string) => () => {
    localStorage.setItem(key, value)
  },
}

RTE “modules”

With Reader dependencies, a common pattern is to expose them as “module” interfaces that expose the service as a single object key. We did this above with HttpClientEnv and StorageEnv.

interface HttpClientEnv {
  httpClient: HttpClient
}

interface StorageEnv {
  storage: Storage
}

The idea with this is that when we start to compose Reader effects, we can combine the reader dependencies using a simple type-level & intersection, like:

const httpClientEnv: HttpClientEnv = { httpClient: fetchHttpClient }

const storageEnv: StorageEnv = { storage: domStorage }

type AppEnv = HttpClientEnv & StorageEnv // & other envs types

const appEnv: AppEnv = { ...httpClientEnv, ...storageEnv /* ...other impls */ } // { httpClient, storage, ... }

The spread of each env module is just for convenience - each env module typically just has one key in it, which needs to just be a unique name for the particular service it contains. This is so when it’s all spread together, we get a big object of all the services to pass as a Reader dependency arg.

One very important thing to note is that you should not write functions that depend on some mega AppEnv type. You should write functions that depend on the exact services that you need, and nothing more. If you depend on just HttpClientEnv and someone passes you the mega AppEnv object, it doesn’t matter, because you are only looking at that one part of it, and the caller is simply over-providing the argument object. Over-providing (which is fine) is not the same as over-depending (which is not fine).

RTE helper functions

With dependencies like HttpClient and Storage, a common pattern is to “re-expose” these interface functions as top-level Reader-based functions that depend on the interface, so that we can use FP techniques for composing RTE operations:

export const request = (
  input: RequestInfo,
  init?: RequestInit,
): RTE.ReaderTaskEither<HttpClientEnv, HttpRequestError, Response> =>
  pipe(
    RTE.asks<HttpClientEnv, never, HttpClient>(
      (env: HttpClientEnv) => env.httpClient,
    ),
    RTE.chainTaskEitherKW((httpClient: HttpClient) =>
      httpClient.request(input, init),
    ),
  )

In this request function, we’ve sort of flipped the interface inside out - rather than an interface that has a function inside it, we have a top-level function that depends on the interface. We’re doing this because now we have a function that we can compose with other RTE functions.

Here, we’re making a function that takes input and init and returns a ReaderTaskEither, which specifies the HttpClientEnv interface as our Reader dependency. We’re using the asks function to get access to the HttpClientEnv input and convert it to just the inner HttpClient that it carries. We then use a chainTaskEitherK to actually invoke the httpClient.request function. Recall that this function returned a TaskEither, because at that level, we had no Reader dependency.

Now we’ll add a few more helper functions, and finally a more high-level helper that combines some of these “lego pieces”:

// Helper for extracting `json` response into `unknown` for decoding
export const toJson = (
  response: Response,
): TE.TaskEither<HttpContentTypeError, unknown> =>
  TE.tryCatch(
    () => response.json(),
    (e: any) => ({ tag: 'httpContentTypeError', error: e }),
  )

// Helper for validating response status
export const ensureStatus = (min: number, max: number) => (
  response: Response,
): E.Either<HttpResponseStatusError, Response> =>
  min <= response.status && response.status < max
    ? E.right(response)
    : E.left({ tag: 'httpResponseStatusError', status: response.status })

// "High-level" helper for issuing a simple GET to a JSON endpoint
export const getJson = <A, DecodeError>(
  url: string,
  decode: (raw: unknown) => E.Either<DecodeError, A>,
): RTE.ReaderTaskEither<
  HttpClientEnv,
  | HttpRequestError
  | HttpContentTypeError
  | HttpResponseStatusError
  | DecodeError,
  A
> =>
  pipe(
    request(url),
    RTE.chainEitherKW(ensureStatus(200, 300)), // ensureStatus operates on Either, so lift it (and widen error type) with chainEitherKW
    RTE.chainTaskEitherKW(toJson), // toJson operates on TaskEither, so lift into RTE (with widening)
    RTE.chainEitherKW(decode), // decode operates on Either... same deal
  )

getJson is a helper function that we’ve built from the lower-level pieces. Note that this is not intended to be the be-all, end-all solution to GET requests - it’s simply a helper that does a particular thing, because this is all we need to do. If we needed to check the response status and dispatch to different response handlers per status, we’d need to do that here by using other RTE combinators. API clients are notorious for trying to “do too much,” and often it’s better to have lower-level tools like this to combine for your particular use cases, rather than trying to write the one all-powerful function for all API calls.

Caching function

Now lets add a localStorage-based caching capability. This function could probably be simplified or done in a better way, but this is just for demonstration purposes.

export const getWithCache = <A, RGet, EGet>(
  key: string,
  codec: t.Type<A>,
  get: RTE.ReaderTaskEither<RGet, EGet, A>,
): RTE.ReaderTaskEither<StorageEnv & RGet, EGet | t.Errors, A> =>
  pipe(
    // Get the Storage service from the Reader
    // Side note: rather than `ask` here, we could have used an "RTE helper function" below that has a dependency on `StorageEnv`
    RTE.asks<StorageEnv, never, Storage>(env => env.storage),
    RTE.chainW(storage =>
      pipe(
        // Try to get the item from the cache
        RTE.fromIO<unknown, never, O.Option<string>>(storage.getItem(key)),
        RTE.chainW((strOpt: O.Option<string>) =>
          pipe(
            strOpt,
            O.fold<string, RTE.ReaderTaskEither<RGet, EGet | t.Errors, A>>(
              () =>
                // Cache miss - make the get call and cache the result
                pipe(
                  get,
                  RTE.chainW((a: A) =>
                    pipe(
                      a, // A
                      codec.encode, // unknown
                      JSON.stringify, // string
                      str =>
                        RTE.fromIO<unknown, never, void>(
                          storage.setItem(key, str),
                        ),
                      RTE.map(_ => a),
                    ),
                  ),
                ),
              str =>
                // Cache hit - parse and decode the item and return it
                pipe(
                  str, // string
                  JSON.parse, // unknown
                  codec.decode, // A
                  RTE.fromEither,
                ),
            ),
          ),
        ),
      ),
    ),
  )

This function uses the StorageEnv and an arbitrary get effect (with it’s own RGet and EGet) to try to get an item from the localStorage-based cache, and if it is not found, issue the get request and cache the result. The resulting RTE type tells the story of what this effect does:

RTE.ReaderTaskEither<StorageEnv & RGet, EGet | t.Errors, A>

This getWithCache effect depends on both the StorageEnv and whatever REnv the get effect requires in order to run. The effect can fail with either a t.Errors decode error (from the io-ts t.Type<A>), or an EGet failure from the get effect. Finally, if successful, the output type is A, which can either come from the cache, or the get.

Domain-level effects

Finally, let’s try to write a real domain-level function using these parts. Let’s fetch the breeds from the Dogs API, and then add a function for doing that with the added caching:

export const breedsCodec = t.type({
  message: t.record(t.string, t.array(t.string)),
})

type Breeds = t.TypeOf<typeof breedsCodec>

// Get breeds via the HTTP API
export const getBreeds: RTE.ReaderTaskEither<
  HttpClientEnv,
  HttpRequestError | HttpContentTypeError | HttpResponseStatusError | t.Errors,
  Breeds
> = getJson('https://dog.ceo/api/breeds/list/all', breedsCodec.decode)

// Add the cached version
export const getBreedsWithCache: RTE.ReaderTaskEither<
  HttpClientEnv & StorageEnv,
  HttpRequestError | HttpContentTypeError | HttpResponseStatusError | t.Errors,
  Breeds
> = getWithCache('breeds', breedsCodec, getBreeds)

Notice the R, E, and A type params in these two functions. The first depends on HttpClientEnv, and the cached version adds the & StorageEnv, because the WithCache function requires the Storage service too.

Using the service

Now we have a function getBreedsWithCache that we could use in a component. In order to call this function, we need to provide the R argument, namely a value of type HttpClientEnv & StorageEnv. We need to get our hands on these services somehow, and there are a variety of ways of doing that, some of which incur some trade-offs in couple and convenience.

In React, it’s typically discouraged to pass around dependencies as React component props, because that can cause unwanted extra renders, so one way to get access to higher-level dependencies is to use Context.

One way to achieve this is to create a Context and corresponding hook for each individual dependency, and the users of the services just need to make sure they are wrapped in a Provider for each layer.

Method one - “context per service”

// App.tsx

// HTTP client module
export const httpClientEnv: HttpClientEnv = {
  httpClient: fetchHttpClient,
}

// HTTP client context
export const HttpClientContext = React.createContext(httpClientEnv)

// HTTP client context hook
export const useHttpClient = () => useContext(HttpClientContext)

// Storage module
export const storageEnv: StorageEnv = {
  storage: domStorage,
}

// Storage context
export const StorageContext = React.createContext(storageEnv)

// Storage custom hook
export const useStorage = () => useContext(StorageContext)

export const App = () => {
  return (
    <HttpClientContext.Provider value={httpClientEnv}>
      <StorageContext.Provider value={storageEnv}>
        <DogsApp />
      </StorageContext.Provider>
    </HttpClientContext.Provider>
  )
}

// DogsApp.tsx
export const DogsApp = () => {

  // This could all be wrapped up into another custom hook
  const httpClientEnv = useHttpClient()
  const storageEnv = useStorage()
  const env = { ...httpClientEnv, ...storageEnv }

  const [remoteData, setRemoteData] = useState<RD.RemoteData<HttpError, Breeds>(RD.initial)

  useEffect(() => {
    setRemoteData(RD.pending)
    RTE.run(getBreedsWithCache, env)
      .then(E.fold(
        e => { setRemoteData(RD.failure(e) },
        a => { setRemoteData(RD.success(a) },
      ))
  }, [])
  // End stuff for custom hook

  // Render remoteData
  return <>...</>
}

Method two - single “AppEnv” context

The other way of doing this is to create a single context that contains all the top-level app services, and basically “over-depend” on this big AppEnv bundle in your components. This method has the convenience that the AppEnv provides all possible dependencies can be passed into any function that depends on any subset of these dependencies. The big downside of this approach is that all the components that have useAppEnv are now coupled to the entire AppEnv dependency collection, so if you want to mock the dependencies, you have to mock all of AppEnv, rather than a smaller subset that you actually need.

// App.tsx

// HTTP client module
export const httpClientEnv: HttpClientEnv = {
  httpClient: fetchHttpClient,
}

// Storage module
export const storageEnv: StorageEnv = {
  storage: domStorage,
}

// Logger (added for demonstration purposes)
export const loggerEnv: LoggerEnv = {
  logger: consoleLogger
}

export type AppEnv = HttpClientEnv & StorageEnv & LoggerEnv

export const appEnv: AppEnv = { ...httpClientEnv, ...storageEnv, ...loggerEnv }

// AppEnv context
export const AppEnvContext = React.createContext(appEnv)

// AppEnv custom hook
export const useAppEnv = () => useContext(AppEnvContext)

export const App = () => {
  return (
    <AppEnvContext.Provider value={appEnv}>
      <DogsApp />
    </AppEnvContext.Provider>
  )
}

// DogsApp.tsx
export const DogsApp = () => {

  // This could all be wrapped up into another custom hook

  // This is convenient from a usage perspective, but makes this component dependent on the full AppEnv,
  // even if we're only using a subset of it. E.g. here, we're not using `LoggerEnv`, but we have to
  // provide it via the context to satisfy the AppEnv type
  const appEnv = useAppEnv()

  const [remoteData, setRemoteData] = useState<RD.RemoteData<HttpError, Breeds>(RD.initial)

  useEffect(() => {
    setRemoteData(RD.pending)
    RTE.run(getBreedsWithCache, appEnv)
      .then(E.fold(
        e => { setRemoteData(RD.failure(e) },
        a => { setRemoteData(RD.success(a) },
      ))
  }, [])
  // End stuff for custom hook

  // Render remoteData
  return <>...</>
}

More methods

Because RTE dependencies are just normal function arguments, you can pass them around as such. You don’t have to use Context for dealing with dependencies - it just seems like kind of the least-friction way to do it in React. The downside of Context is that it creates an implicit dependency in your component with some externally-provided service, so be aware of this secret dependency causing problems.

The upside of Context is that it’s fairly easy to provide mock implementations of dependencies by just wrapping your components in Providers that just inject dummy or mock versions of your dependencies. E.g. in storybook, you’d just need to provide whatever services your component needs, rather than the entire Redux store or a huge stack of strange and unrelated junk.

Service layers and implementation hiding

One thing that feels a little bad about a function like getBreedsWithCache, which directly depends on HttpClientEnv and StorageEnv through the Reader arg, is that we’re exposing our implementation details, and perhaps providing too much power to the users, since they now have to deal with the HttpClient and Storage dependencies directly. Also, the user of this function unfortunately gains access to these lower-level services too, which gives them the power to do unwanted things.

One thing you can do to hide these details is to layer your services and provide these more abstract services with the implementations pre-provided via applying the Reader args. I will not go into a lot of detail, but something like this:

// More abstract domain-level service, suitable for use in app components
// (or at least more suitable than the HttpClient or Storage services)
interface BreedService<E> {
  getBreeds: TE.TaskEither<E, Breeds>
}

// RTE module interface
export interface BreedServiceEnv<E> {
  breedService: BreedService<E>
}

export type HttpError =
  | HttpRequestError
  | HttpContentTypeError
  | HttpResponseStatusError
  | t.Errors

// Reader that takes the RTE's env inputs and just applies them to the RTE, leaving behind just ready-to-run TaskEither
// which hides the implementation of the interface.
export const breedServiceWithCache: R.Reader<
  HttpClientEnv & StorageEnv,
  BreedService<HttpError>
> = pipe(
  R.ask<HttpClientEnv & StorageEnv>(),
  R.map(
    (env): BreedService<HttpError> => ({
      getBreeds: getBreedsWithCache(env), // Apply the `Reader` arg, leaving just the TaskEither
    }),
  ),
)

Here, we’ve created a more abstract BreedService interface, which does not expose the underlying implementation details of HttpClient and Storage.An implementation of this interface can be created using a plain old Reader from the required HttpClientEnv & StorageEnv reader dependencies into an instance of the BreedService. We’ve layered the BreedService on top of the lower-level services and hidden the details from a prospective user of this interface. The error type E is exposed via the interface because the error type could change depending on the underlying implementation, so we’d want the caller to have the opportunity to deal with different error types. Exposing the error type in the interface is desirable, because you’d want your users to know if you changed the implementation in a way that resulted in the possibility of different types of errors. You could alternatively abstract the error type away if you wanted to hide that implementation detail too. One final note on this is that you can partially provide the Reader dependencies and leave others unprovided. It might be the case that you want to expose certain dependencies at certain levels, but hide them in other layers. This can be done by providing a subset of a function’s overall dependencies, and leaving the others still needed, to be provided elsewhere.

This BreedService could be exposed to components in the same way as the lower-level env services, using Context and corresponding custom hooks. In this case, the caller would have something like useBreedService, which provides the instance of the BreedService<E> interface with the dependencies already provided or baked-in.

// App.tsx

// HTTP client module
// This could be exposed as its own context if you want callers to be able to get it directly, but here we'll hide it
const httpClientEnv: HttpClientEnv = {
  httpClient: fetchHttpClient,
}

// Storage module
// This could be exposed as its own context if you want callers to be able to get it directly, but here we'll hide it
const storageEnv: StorageEnv = {
  storage: domStorage,
}

type AppEnv = HttpClientEnv & StorageEnv

const appEnv: AppEnv = { ...httpClientEnv, ...storageEnv }

// Create our implementation of the interface which applies the dependencies
const breedServiceImpl: BreedService<HttpError> = breedServiceWithCache(appEnv)

// BreedService module which hides the dependencies
export const breedServiceEnv: BreedServiceEnv<HttpError> = {
  breedService: breedServiceImpl
}

// Custom context to get just the BreedService<E> (here E is HttpError because of our chosen implementation)
export const BreedServiceContext = React.createContext(breedServiceEnv)

// Users of this get an `BreedService<E>` where `E` depends on the chosen implementation, which is configured at this level
// Users do not get access to the underlying HttpClientEnv or StorageEnv.
export const useBreedService = () => useContext(BreedServiceContext)

export const App = () => {
  return (
    <BreedServiceContext.Provider value={breedServiceEnv}>
      <DogsApp />
    </BreedServiceContext.Provider>
  )
}

// DogsApp.tsx
export const DogsApp = () => {

  // This could all be wrapped up into another custom hook
  const breedService = useBreedService()

  const [remoteData, setRemoteData] = useState<RD.RemoteData<HttpError, Breeds>(RD.initial)

  useEffect(() => {
    setRemoteData(RD.pending)
    breedService.getBreeds() // getBreeds is a TaskEither, so just call it to create the Promise and run it
      .then(E.fold(
        e => { setRemoteData(RD.failure(e) },
        a => { setRemoteData(RD.success(a) },
      ))
  }, [])
  // End stuff for custom hook

  // Render remoteData
  return <>...</>
}

With this implementation, we’ve exposed more domain-level services for our components to use, and hidden some of the lower-level dependencies. With this approach, you’d probably want to use the “context per service” approach, and make your components only depend on the exact dependencies they need. You typically want to make your dependencies as small as possible, so that the components limit their dependency on things they don’t need.

There’s not necessarily a right or wrong way to implement these things - this is intended to just go over some of the many approaches that one can take.

Conclusion

ReaderTaskEither is a flexible and highly-expressive effect type for building applications. We can compose effect functions and layer them from the low-level infrastructure services, up to application-level services. We can expose these effects to our components using a variety of techniques, including React Context and custom hooks.

comments powered by Disqus