DEV Community

loading...
Cover image for fp-ts and Beautiful API Calls

fp-ts and Beautiful API Calls

gnomff_65 profile image Timothy Ecklund ・4 min read

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!

Discussion

pic
Editor guide
Collapse
urgent profile image
thisbounty.com

Very helpful. I was validating input, and then generating a task. Much easier to just start with the input in the task container, and validating that with chain.

One thing is fat arrows and generics do not compile in tsx files, typescript with JSX support. Instead I used a function. Also failure needs an import from io-ts/lib/PathReporter

Collapse
gpaoloni profile image
Gianfranco Paoloni

Awesome post, thank you!