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!
Top comments (5)
Great post! Had a little trouble with the code as written -- might be my Typescript config or some update to fp-ts. Had to provide type arguments to flow of decodeWith due to an "Type argument cannot be inferred from usage" error. Once decodeWith was changed to the following, it all worked:
Thank you!
Awesome post, thank you!
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
TE.taskEither
seems to be deprecated - any idea what is the alternative?Here's a suggestion: github.com/gcanti/fp-ts/issues/149....
"Replace TE.taskEither with either TE.ApplySeq or TE.ApplyPar"