This article will demonstrate how to leverage the fp-ts library to make async requests in typescript using functional programming.
Say we want to make an HTTP request. From that we know at least the following:
- We need an HTTP client to make the call.
- We need to handle a
Promise
. - We need to anticipate errors.
Let's reiterate those three points, this time in terms of what they signify.
- A dependency.
- An asynchronous call.
- Something that can go wrong.
In functional programming we use certain data types to deal with these types of complexity. In our case specifically:
- Reader - for dependency injection.
- Task - for asynchronous things.
- Either - for things that can go wrong.
We could compose these monads ourselves, but fp-ts provides us with some useful compositions out of the box, like the ReaderTaskEither. That is a Reader containing a Task containing an Either. Let's go over them and see what we can do with their composition.
Note: The code snippets containing a describe
block can be executed with Jest or Jasmine.
1. Reader
Reader<A, B>
is just a function from A
to B
. But having that type alias is very useful: it means we want to do dependency injection.
import * as R from 'fp-ts/lib/Reader'
describe('Reader', () => {
it('should just be a function', () => {
const myReader: R.Reader<number, string> = (a: number) => String(a)
expect(myReader(15)).toBe('15')
})
}
If you know dependency injection then you are probably used to injecting class instances into constructors to build up a dependency tree, after which you send in the parameters from a top level function that will then traverse the tree. This means dependencies get injected first and function parameters get passed second. In functional programming it goes the other way around. We first inject the parameters in order to create a function of external dependencies. Only then do we send in those dependencies to execute the pipe. Let's see how this works in practice:
Note that the types HttpClient
and Response
are hypothetical.
// imperative example
class MyController {
private HttpClient client
constructor(HttpClient client) {
this.client = client
}
async fetch(path: string): Response {
return await this.client.get(path)
}
}
// ...
const controller = new MyController(new HttpClient()) // inject dependencies
const res: Response = await controller.fetch('events') // pass parameters and execute
// functional example
import * as R from 'fp-ts/Reader'
// ...
const fetch = (url: string) =>
R.asks((client: HttpClient) => client.get(url))
const fetchEvents = fetch('events') // pass parameters
const res: Response = await fetchEvents(new HttpClient() // inject depdencies and execute
asks<A, B>(f)
will return a reader that will provide A
as a parameter to f
, which in turn must return a B
.
You can find a more detailed explanation of the Reader monad in this article: Getting started with fp-ts: Reader.
A note on pipelines
That last example was straightforward, but operations like that involve more logic before and after our
fetch
function. In functional programming we use pipelines to compose functions for more complicated operations. With fp-ts we can use pipe or flow for that. We can rewritefetchEvents
as follows:// with pipe const fetchEvents = pipe('events', fetch) // with flow const fetchEvents = flow(fetch)('events')
With
pipe
andflow
we can easily put more functions in:
- Put them in front of
fetch
for things we want to do to the >events
parameter before calling fetch.- Put them after
fetch
for things we want to do to the result of callingfetch
.
2. Task
A Task
is a function that returns a Promise
. That way we can defer execution of the Promise
until we call upon the Task
.
import * as T from 'fp-ts/lib/Task'
describe('Task', () => {
it('of should work', async () => {
// Arrange
const mytask = T.of('abc')
// Act
const result = await mytask()
// Assert
expect(result).toEqual('abc')
})
it('should work with promise', async () => {
// Arrange
const mytask: T.Task<string> = () => Promise.resolve('def')
// Act
const result = await mytask()
// Assert
expect(result).toEqual('def')
})
})
3. Either
Either
is a way of anticipating failures. It has two instances; left
and right
- left
indicating a failure and right
indicating that everything is OK.
import * as E from 'fp-ts/lib/Either'
describe('Either', () => {
it('of should create a right', () => {
// Act
const myEither = E.of('abc')
// Assert
expect(myEither).toEqual(E.right('abc'))
})
})
Either
has some helper constructors, like fromNullable
which returns a left if the parameter is null
or undefined
.
import * as E from 'fp-ts/lib/Either'
describe('Either.fromNullable', () => {
const errorMessage = 'input was null or undefined'
const toEither = E.fromNullable(errorMessage)
it('should return right with string', async () => {
// Arrange
const expected = 'abc'
// Act
const myEither = toEither(expected)
// Assert
expect(myEither).toEqual(E.right(expected))
})
it('should return left with undefined', async () => {
// Act
const myEither = toEither(undefined)
// Assert
expect(myEither).toEqual(E.left(errorMessage))
})
})
4. Task + Either = TaskEither
TaskEither
is an Either
inside of a Task
, meaning it's an asynchronous operation that can fail. It provides us with the useful function tryCatch
that takes two parameters: The first is a function that returns a promise, the second is function that maps the rejected result to something that ends up in a left
- we'll just use the String
constructor for that in this example:
import * as TE from 'fp-ts/lib/TaskEither'
import * as E from 'fp-ts/lib/Either'
describe('TaskEither', () => {
it('should work with tryCatch with resolved promise', async () => {
// Arrange
const expected = 135345
// Act
const mytask = TE.tryCatch(() => Promise.resolve(expected), String)
const result = await mytask()
// Assert
expect(result).toEqual(E.right(expected))
})
it('should work with tryCatch with rejected promise', async () => {
// Arrange
const expected = 'Dummy error'
// Act
const mytask = TE.tryCatch(() => Promise.reject(expected), String)
const result = await mytask()
// Assert
expect(result).toEqual(E.left(expected))
})
})
Click here to go to an extensive article about Task, Either and TaskEither.
5. Reader + Task + Either = ReaderTaskEither
Now let's put the parts together to create a functional data structure that we can use to make an HTTP request. Let's begin with a naive approach:
import * as TE from 'fp-ts/lib/TaskEither'
const client = new HttpClient()
const myTryCatch = TE.tryCatch(() => client.get('events'), String)
We refactor to parameterize the client and the url, using currying.
const myTryCatch = (url: String) => (client: Client) => TE.tryCatch(() => client.get(url), String)
Let's declare the type of this function:
type MyTryCatch = (url: string) => (client: HttpClient) => TE.TaskEither<string, Response>
const myTryCatch: MyTryCatch = (url) => (client) => TE.tryCatch(() => client.get(url), String)
Now we are going to rewrite the type using Reader
:
type MyTryCatch = (url: string) => Reader<HttpClient, TE.TaskEither<string, Response>>
And as you've probably guessed, a Reader
returning a TaskEither
is a ReaderTaskEither
:
import * as RTE from 'fp-ts/lib/ReaderTaskEither'
type MyTryCatch = (url: string) => RTE.ReaderTaskEither<HttpClient, string, Response>
This is a single type that tells you all you need to know about the logic it encapsulates:
- It has a dependency on
HttpClient
- The error gets formatted to a
string
- A successful execution will result in a
Response
Seeing it in action
The nice thing about Reader
is that we can obtain the dependency anywhere in a pipe using asks
, which we've discussed earlier in this article. This is how it would work:
import * as RTE from 'fp-ts/lib/ReaderTaskEither'
import * as TE from 'fp-ts/lib/TaskEither'
import * as R from 'fp-ts/lib/Reader'
// ...
pipe(
// ...functions leading up to a ReaderTaskEither containing a userId
RTE.chain((userId) =>
R.asks((client: HttpClient) => TE.tryCatch(() => client.get(`user/${userId}`), String))
)
// ...functions handling the response
)
Typically you would extract this function to make the pipe more readable.
pipe(
// ...
RTE.chain(fetchUser)
// ...
)
// ...
const fetchUser = (userId: string) => (client: HttpClient) => TE.tryCatch(() => client.get(`user/${userId}`), String))
If
chain
is unfamiliar to you, it takes a function that maps whatever the monad is holding and returns a monad of something else. In this example it is assumed we start with aReaderTaskEither
of astring
userId and we chain it to aReaderTaskEither
of aResponse
. For more information about monads andchain
in fp-ts checkout Getting started with fp-ts: Monad
Conclusion
Doing functional programming in typescript is not required and therefore a discipline. Nothing is forcing you to use a ReaderTaskEither
for asynchronous operations, but the reward is a function that you can use in any pipe that by its signature is honest about what it does. It also makes for excellent testability: by using Reader
we don't have to worry about instantiating a class that may or may not require more dependencies than we care about for a given test.
Checkout the official documentation for more information.
Top comments (5)
Is the last example actually working? Where is the ReaderTaskEither provided with the client when used in the context of a pipe? My head is spinning :-)
You would either provide it at the start of the pipe or create it within, as long as it is there before the
RTE.chain
. Suppose for example you start off with anEither
, you could use the functionRTE.fromEither
and switch to aReaderTaskEither
.Can you discuss a little about pitfalls / complications / things to avoid when using Ether<E, A>? I'm new to them but we are starting to use them a lot at work. I'd like to make sure I don't do unforeseen things that will bite us later. Thanks!
I would say:
Either
- for example don't return aright
when you have an error.Either
when you neeed something else; a common use case is validation where you need to collect multiple validation errors instead of a single error. Checkout this article on Either vs ValidationHope that helps, cheers!
Where can I find a full working example of the code used in this article?