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 Promise
s. 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 Promise
s 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 RTE
s 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 Provider
s 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.