Typesafe Domain Services in React with fp-ts and ReaderTaskEither
Let's build a service. What is a Service, you ask? I cover this in What is a Service?.
One of the things we do often and repeatedly in a single-page client application is fetch data from an HTTP endpoint. This data more-often-than-not corresponds to the concepts inside our domain. In our domain, we love working with pure data. However, the process for fetching it is often anything but pure.
In a nice, pure FP world, we would expect something like this.
sequenceDiagram participant View participant Domain participant Server View->>Domain: Whoa, an event just happened! Domain->>Server: Yo, I need data. Server-->>Domain: Here you go. Domain-->>View: Ready to render!
However, in practice, this becomes a lot more complex. Requests to the server can fail. We may need to access outside state. We may get errors deserializing results. All of these concerns fall well outside of the responsibility of our domain.
In fact, our domain should not even be talking to the HTTP client at all. That falls well outside of its responsibility. As does authentication, error handling and deserializing. We need to rely on a third party to broker communication between the mechanism that delivers and our domain, which shapes it. That third party is our service.
flowchart LR View-->|some event|Service Service-->|request|Server Server-->|response|Service Service-->|ask|AuthClient AuthClient-->|token|Service Service-->|data|Domain Domain-->|model|Service Service-->|trigger render|View
I think most of the time when we picture a broker like this, we picture an object, something that's stateful. After all, it needs to interact with the messy outside world.
But what if we could do it all in a single function? By which I mean, a single composed function. A function that is made up of several tinier functions. But it's pure and stateless.
Let's dig in. What do we need this service to do?
- Request data over HTTP
- Handle errors
- Construct our view model from returned data
- Validate data received is what we expect
- Support multiple environments (host names) for endpoints
- Support fetching an authentication token
- Add error and debug logging
Fetching Data with Task
Okay, let's start with the simplest version. Let's issue a GET request to an endpoint.
const httpGet = (url: string): Promise<AxiosResponse> => axios.get(url)
So we have a function of string -> Promise
. Cool. But there's a small issue with using Promise
as the return type. A Promise
always beings execution as soon as it is created. But as a functional programmer, I often want to exert a little more control over when execution happens (read: laziness). In fact, I might want to apply the URL to this function well before I intend to execute it. So let's make one small change:
const httpGet = (url: string) => (): Promise<AxiosResponse> => axios.get(url)
So now when I invoke httpGet('http://example.com')
, instread of getting an in-progress Promise
, I instead get a parameterless function than I invoke somewhere else when I intend to kick off my request.
const getExample = httpGet('http://example.com');
const runProgram = async () => {
const response = await getExample();
console.log(response.status)
}
This is often called a thunk (as in, the thinking it already done, this function has 'thunk'). In the world of fp-ts
, a thunk around an asynchronous execution can also be called a Task and typed accordingly.
import { Task } from 'fp-ts/Task'
const httpGet = (url: string): Task<AxiosResponse> => () => axios.get(url)
Typing this as a task opens up some options for us. Task is a type class in fp-ts
which means it can conform to a number of interfaces.
If want to access the value of the underlying promise of the Task, we can use Task.map
:
import { pipe } from 'fp-ts/function'
import * as T from 'fp-ts/task'
const getPerson = (id: string) => pipe(
httpGet(`http://example.com/person/${id}`)
T.map((response) => new Person(response.data))
)
await getPerson(123) // Person { ... }
And if we want to execute one Task and feed its result into another task, we can do that with Task.chain
:
import { pipe } from 'fp-ts/function'
import * as T from 'fp-ts/task'
const getPerson = pipe(
httpGet('http://example.com/person/1')
T.map((response) => new Person(response.data))
)
const getRecord = (person: Person) => pipe(
httpGet(`http://example.com/records/`${person.recordId}),
T.map((response) => new Record(response.data))
)
const getRecordForPerson = (id: string) => pipe(
getPerson(id),
T.chain(person => getRecord(person))
)
await getRecordForPerson(123) // Record { ... }
See what we've done here? We've created two wholly separate and pure service functions, and composed them together to create a third service function. This whole idea of taking very simple functions and bringing them together as building blocks for a more complex pipeline of operation is how we're going to build our http service layer.
Take notice of the map
and chain
functions. This post doesn't really cover the what or why of type classes, but those functions are part of the common interface between many of the types we encounter in fp-ts
and we will see them quite a bit in our travels.
Handling Failure with TaskEither
Okay, it's time to fess up. I've been lying to you (slightly). In the previous section, I mentioned that you can fetch data over HTTP by ecapsulating a Promise inside of a Task. That's not entirely accurate. Sure, it might work. But did you notice something about our implementation? We never handled a failure case. Because Task can't deal with rejection.
So let's back up a bit and talk about Task a little more before we move on. In the previous section you might have gotten the impression that Tasks are analgous to Promises. That's not really true. You may have thought that a task is just an alternative to handling asynchronous code. And that is only a half truth.
If we look at definition of Task in the fp-ts
docs, it states the following:
Task<A>
represents an asynchronous computation that yields a value of typeA
and never fails. If you want to represent an asynchronous computation that may fail, please seeTaskEither
.
Oh. Tasks should never fail. So we should ask ourselves: do HTTP requests fail? Of course they do. All the damn time. So it's disigenuous to try to represent an HTTP request as a task. The docs advise using something called TaskEither
.
TaskEither
is pretty much exactly what it says on the tin. It is a Task that yields either a success value or a failure value. Whereas a Task<A>
only allows us to wrap a type that represent success, TaskEither<E, A>
allows us to represent both success and failure.
So we've already explained the ideas behind Task
. Now, let's talk a bit about Either
.
Let me be clear. A Task
is its own type with an interface. An Either
is also it's own standalone type with an interface. A TaskEither
is also it's own type and it's own interface. However, a TaskEither
can be regarded like a Task
and an Either
that have been smooshed together.
So an Either
is a type that represents the sum of success and failure. While we're working with an Either
, we can address the success case and the failure case simultaneously, before we ever need to know the actual result. It's another situation where we can declare our intention around an operation prior to running it.
The type parameters for an Either look like Either<E,A>
. We consider E
to be the failure value and A
to be the success value. We will also refer to E
as the left value and A
as the right value.
Left and right can be Either constructors as well. If we have a value that already represents success or failure, we can contruct the either accordingly.
import * as E from 'fp-ts/Either'
import { Either } from 'fp-ts/Either'
const aFoo: Either<Error, string> = E.right("foo");
const notAFoo: Either<Error, string> = E.left(new Error("error"));
Of course, we're probably more interested in creating an either when evaluating a given expression:
const makeFoo: Either<Error, "foo"> = E.fromPredicate(
(val: string) => val === "foo", // a predicate to validate success
() => new Error("must be foo") // the failure value
)
// Note: Right and Left are type constructor for Either
makeFoo("foo") // => Right("foo")
makeFoo("bar") // => Left(Error("must be foo"))
Once we've constructed an Either, we can then pass it around as a value, and lazily declare operations on it's underlying values:
// In the case of Either, `map`, will always operate on the right (success) value.`mapLeft` acts like `map`, but over left (failure) value.
const makeFOO = flow(
makeFoo,
E.map(foo => foo.toUppercase()),
E.mapLeft(err => ({ message: `An error ocurred: ${err}`}))
)
// Either is also a Bifunctor, so we can also just use `bimap` which takes two functions for left and right values
const makeFOO = flow(
makeFoo,
E.bimap(
err => ({ message: `An error occurred: ${err}`}),
foo => foo.toUpperCase()
)
)
Up until this point, it did not matter what the actual underlying value of our Either was. We could pretend it was successful, or we could pretend it errored. Our program did not care as long as we were engaged with the Either
type. Eventually, of course, we will need to get at that value. To do that, we need to deconstruct, or unwrap, the Either.
We can use Either.match
(also known as Either.fold
) to apply a function to the underlying values.
const currentStatus = flow(
makeFOO,
E.match(
err => err.message,
identity
)
)
const fooStatus = currentStatus("foo") // => "FOO"
const barStatus = currentStatus("bar") // => "An error occurred: must be foo"
If we don't care about the specifics of the failure case, we can also just get the success value, or fallback to a default value.
const currentStatus = flow(
makeFOO,
E.getOrElse(() => "error")
)
currentStatus("foo") // => "FOO"
currentStatus("bar") // => "error"
We've been using a contrived example here to demonstrate Either
. I don't want to give you the false impression that Either
is a good type for representing values that are optional. Either
is designed to encapsulate operations that would normally have failure state whose details we are interested in. If you find that you either don't care about your failure state, or always fallback to an empty or default value you should probably be using the Option
type instead
Onto TaskEither
So the big takeaway around using Either
is that it allows us to pass around a value that can represent the output of an operation without having to decide what to do with the value of that output the second that operation occurs. Compared to a Promise, an Either doesn't force us down an exception-handling path, either. We still only have a single function pipeline.
So if a Task provides us with the asynchronicity of a Promise, and the Either allows us to elegantly handle errors, what we really want is a combination of the two. A TaskEither, if you will.
Well, fp-ts
has us covered in that regard. And building one out of a Promise thunk is almost as simple as building our task.
import * as TE from 'fp-ts/TaskEither'
export const httpGet = (url: string) => {
return TE.tryCatch<Error, AxiosResponse>(
() => axios.get(url),
(reason: unknown) => {
return new Error(`${reason}`)
}
)
}
We are using TE.tryCatch
to handle any potential exceptions from the Promise, giving us our Either
.
And that's it, really. Once you understand Task
and Either
, using TaskEither
becomes pretty straightforward. You've got a similar set of functions. You can map
, mapLeft
, match
, chain
, etc.
import * as TE from 'fp-ts/TaskEither'
const getPerson = pipe(
httpGet('http://example.com/person/1'),
TE.map((response) => new Person(response.data)),
TE.getOrElse(EMPTY_PERSON),
)
const PersonComponent = () => {
const [person, setPerson] = useState(null)
useEffect(() => {
const person = await getPerson(1)
setPersion(person)
}, [])
return (
<p>Hello, {person.name}</p>
)
}
Sprinkle in some domain with Decoder
Okay, so let's check in on our progress:
Request data over HTTPHandle errors- Construct our view model from returned data
- Validate data received is what we expect
- Support multiple environments (host names) for endpoints
- Support fetching an authentication token
- Add error and debug logging
So now we want to actually connect our HTTP fetching to our domain. We should be able to verify that we've received a correct data 'shape' from the server -- ideally something that matches a domain object.
To do this, we'll lean on another library in the fp-ts
family of modules: io-ts
, which allows us to perform runtime type checking on objects.
I don't talk much about a 'domain layer' in this post, but it's assumed there exists a collection defined object types (entities, value types, etc) and functions for working with them that correspond to the business domain of your app. io-ts
is a helpful library, but it not the domain itself. We're using it here to validate the raw data received from network requets. Shaping and aggregating that data correctly the responsibility of the domain.