fp-ts and Beautiful API Calls
Timothy Ecklund
Posted on March 5, 2020
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 ValidationError
s 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!
Posted on March 5, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.