fp-ts and Beautiful API Calls

gnomff_65

Timothy Ecklund

Posted on March 5, 2020

fp-ts and Beautiful API Calls

Last time we visted fp-ts, we made concurrent API calls but didn't spend any time on error handling or keeping it DRY (Don't Repeat Yourself). Well we're a bit older and wiser now, and it's time to re-visit. Let's add some elegant error handling and tighten things up. Here's what we had last time:

const getUser = pipe(
  httpGet('https://reqres.in/api/users?page=1'),
  TE.map(x => x.data),
  TE.chain((str) => pipe(
    users.decode(str), 
    E.mapLeft(err => new Error(String(err))), 
    TE.fromEither)
  )
);

const getAnswer = pipe(
  TE.right("tim"),
  TE.chain(ans => pipe(
    answer.decode({ans}), 
    E.mapLeft(err => new Error(String(err))), 
    TE.fromEither)
  )
)

Bleh, there is a lot of duplication. Also our errors are going to be useless. If we run that the code above we get Error: [object Object]. What the heck is that? Completely useless, that's what. We can do better. First thing, let's make our error messages actually readable.

import { failure } from 'io-ts/lib/PathReporter'

const getAnswer = pipe(
  TE.right("tim"),
  TE.chain(ans => pipe(
    answer.decode({ans}), 
    E.mapLeft(err => new Error(failure(err).join('\n'))), 
    TE.fromEither)
  )
)

The failure method from the io-ts PathReporter takes an array of ValidationErrors and gives back a string. If we run this we get Error: Invalid value "tim" supplied to : { ans: number }/ans: number which is definitely a lot more helpful. Nice.

Ok, next up let's see what we can do to get rid of that gross duplication.

const decodeWith = <A>(decoder: t.Decoder<unknown, A>) =>
  flow(
    decoder.decode,
    E.mapLeft(errors => new Error(failure(errors).join('\n'))),
    TE.fromEither
  )

const getUser = pipe(
  httpGet('https://reqres.in/api/users?page=1'),
  TE.map(x => x.data),
  TE.chain(decodeWith(users))
);

const getAnswer = pipe(
  TE.right({ans: 42}),
  TE.chain(decodeWith(answer))
)

Well that looks way better. decoder.decode takes an unknown and gives back an Either<Errors, A> which is perfect. But getUser is still pretty specific to that url and to that type which is uncomfortable. One more time:

const getFromUrl = <A>(url:string, codec:t.Decoder<unknown, A>) => pipe(
  httpGet(url),
  TE.map(x => x.data),
  TE.chain(decodeWith(codec))
);

Aw yis. Now we can make any API call that we want and the response will be validated against our codec. We can even throw in a TE.mapLeft after httpGet if we want to do something fancy with errors thrown by axios.

Let's put it all together.

import axios, { AxiosResponse } from 'axios'
import { flatten, map } from 'fp-ts/lib/Array'
import * as TE from 'fp-ts/lib/TaskEither'
import * as E from 'fp-ts/lib/Either'
import * as T from 'fp-ts/lib/Task'
import { sequenceT } from 'fp-ts/lib/Apply'
import { pipe } from 'fp-ts/lib/pipeable'
import { flow } from 'fp-ts/lib/function'
import { failure } from 'io-ts/lib/PathReporter'
import * as t from 'io-ts'

//create a schema to load our user data into
const users = t.type({
  data: t.array(t.type({
    first_name: t.string
  }))
});
type Users = t.TypeOf<typeof users>

//schema to hold the deepest of answers
const answer = t.type({
  ans: t.number
});

//Convert our api call to a TaskEither
const httpGet = (url:string) => TE.tryCatch<Error, AxiosResponse>(
  () => axios.get(url),
  reason => new Error(String(reason))
)

//function to decode an unknown into an A
const decodeWith = <A>(decoder: t.Decoder<unknown, A>) =>
  flow(
    decoder.decode,
    E.mapLeft(errors => new Error(failure(errors).join('\n'))),
    TE.fromEither
  )

//takes a url and a decoder and gives you back an Either<Error, A>
const getFromUrl = <A>(url:string, codec:t.Decoder<unknown, A>) => pipe(
  httpGet(url),
  TE.map(x => x.data),
  TE.chain(decodeWith(codec))
);

const getAnswer = pipe(
  TE.right({ans: 42}),
  TE.chain(decodeWith(answer))
)

const apiUrl = (page:number) => `https://reqres.in/api/users?page=${page}`

const smashUsersTogether = (users1:Users, users2:Users) =>
  pipe(flatten([users1.data, users2.data]), map(item => item.first_name))

const runProgram = pipe(
  sequenceT(TE.taskEither)(
    getAnswer, 
    getFromUrl(apiUrl(1), users), 
    getFromUrl(apiUrl(2), users)
  ),
  TE.fold(
    (errors) => T.of(errors.message),
    ([ans, users1, users2]) => T.of(
      smashUsersTogether(users1, users2).join(",") 
      + `\nThe answer was ${ans.ans} for all of you`),
  )
)();

runProgram.then(console.log)
George,Janet,Emma,Eve,Charles,Tracey,Michael,Lindsay,Tobias,Byron,George,Rachel
The answer was 42 for all of you

And if we return erroneous data like:

const getAnswer = pipe(
  TE.right({ans: "tim"}),
  TE.chain(decodeWith(answer))
)

we get

Invalid value "tim" supplied to : { ans: number }/ans: number

Damn that's pretty. With this pattern we can handle any API calls that:

  • Can error
  • Return something that needs validation
  • Run in sequence, in parallel or by themselves

with complete confidence that all of the edge cases are covered. Stay (type)safe out there!

💖 💪 🙅 🚩
gnomff_65
Timothy Ecklund

Posted on March 5, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

fp-ts and Beautiful API Calls
typescript fp-ts and Beautiful API Calls

March 5, 2020