DEV Community 👩‍💻👨‍💻

Cover image for Functional programming in typescript using fp-ts: ReaderTaskEither
peerhenry
peerhenry

Posted on • Updated on

Functional programming in typescript using fp-ts: ReaderTaskEither

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:

  1. We need an HTTP client to make the call.
  2. We need to handle a Promise.
  3. We need to anticipate errors.

Let's reiterate those three points, this time in terms of what they signify.

  1. A dependency.
  2. An asynchronous call.
  3. Something that can go wrong.

In functional programming we use certain data types to deal with these types of complexity. In our case specifically:

  1. Reader - for dependency injection.
  2. Task - for asynchronous things.
  3. 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')
  })
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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
Enter fullscreen mode Exit fullscreen mode

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 rewrite fetchEvents as follows:

// with pipe
const fetchEvents = pipe('events', fetch)

// with flow
const fetchEvents = flow(fetch)('events')

With pipe and flow 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 calling fetch.

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')
  })
})
Enter fullscreen mode Exit fullscreen mode

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'))
  })
})
Enter fullscreen mode Exit fullscreen mode

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))
  })
})
Enter fullscreen mode Exit fullscreen mode

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))
  })
})
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

We refactor to parameterize the client and the url, using currying.

const myTryCatch = (url: String) => (client: Client) => TE.tryCatch(() => client.get(url), String)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Now we are going to rewrite the type using Reader:

type MyTryCatch = (url: string) => Reader<HttpClient, TE.TaskEither<string, Response>>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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 a ReaderTaskEither of a string userId and we chain it to a ReaderTaskEither of a Response. For more information about monads and chain 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)

Collapse
 
abolz profile image
Andreas Bolz

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 :-)

Collapse
 
peerhenry profile image
peerhenry Author • Edited on

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 an Either, you could use the function RTE.fromEither and switch to a ReaderTaskEither.

Collapse
 
agiesey profile image
AGiesey • Edited on

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!

Collapse
 
peerhenry profile image
peerhenry Author • Edited on

I would say:

  1. Don't misuse Either - for example don't return a right when you have an error.
  2. Don't use 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 Validation

Hope that helps, cheers!

Collapse
 
gburnett profile image
gburnett

Where can I find a full working example of the code used in this article?

🌚 Friends don't let friends browse without dark mode.

Sorry, it's true.