Collecting multiple errors in fp-ts
I fell in love with fp-ts for the conciseness of code that it offers. The other feature that I like is that it makes it easy to propagate and handle errors in a type-safe way. This led to me writing code that is more precise and easier to test. When writing business logic, I explicitly list the possible errors that can happen. Then, these errors are propagated by glue functions that call the business logic, to be shown in the UI later.
The great thing is that with fp-ts
, TypeScript helps unwrap the error, so I no
longer write code where I need to defensively check what the thrown error is:
try {
someOperation();
} catch (error) {
if (error instanceof Error) {
showToast(error.message);
} else if (typeof error === "string") {
showToast(error);
} else {
console.log("Error", error);
showToast("Unknown error occurred");
}
}
Instead, this code looks like:
pipe(
someOperation(),
either.match(
(error): undefined => {
switch (error.type) {
case "name-too-short":
showToast("The username is too short");
return;
case "age-too-low":
showToast("You must be at least 21 to use this website");
return;
// Errors are exhaustively handled.
// There are no more possible error types.
// TypeScript will show an error if some error type is not handled.
}
},
(result) => {
// Process the result in some way...
}
)
);
If you want to see TypeScript expecting exhaustively handling each switch
variant, see
this TypeScript playground.
The snippet that uses fp-ts is longer, that is true. The tradeoff is that the
error
is strictly typed. When hovering over the error
type, you can see all
the possible expected errors.
Compare that with errors caught in a catch
clause which have the unknown
type.
What if there are many operations that can fail?
I see two main categories of running multiple operations:
-
Executing the same operation for each element in an array (aka
Array.prototype.map
) (the order of results does not matter). -
Executing different operations (the order of results matters).
Combining errors from an array of Either
s
Let's focus on the following piece of code:
type NumberProcessingError = "cannot divide by zero" | "NaN";
function processNumber(
num: number
): either.Either<NumberProcessingError, number> {
if (Number.isNaN(num)) {
return either.left("NaN");
} else if (num === 0) {
return either.left("cannot divide by zero");
}
return either.right(10 / num);
}
const result = pipe([1, 2, 3, NaN, 0, 5], array.map(processNumber));
In this case, we are dealing with an array of Either
s. result
has the type
Either<NumberProcessingError, number>[]
. How can we tell if processing all
items in the array succeeded?
We could check if there is at least one item in the array that is Left
(error). If all items are Right
, we can remove the Either
wrapping from them
and continue working with just an array of numbers. This should be more
convenient than working with an array of Either
s.
What I have just described is exactly the behavior of
either.sequenceArray
.
It takes an array of Either<E, A>
and returns Either<E, A[]>
.
In other words, you give it an array of results that could have failed, and it will return you a single result that is either the first error encountered in that results array, or the right values from those results.
Let's see that in practice. Let's attach either.sequenceArray
at the end of
our data processing pipeline:
pipe(
[1, 2, 3, NaN, 0, 5],
array.map(processNumber),
// NOTE: this is new
either.sequenceArray,
either.match(
(error) => {
console.log("Processing some number failed", error);
},
(numbers) => {
console.log(
"Processing all numbers succeeded. Here are the results:",
numbers
);
}
)
);
Using either.sequenceArray
makes it easier to get to the correct results and
process them further. The tradeoff of how easy either.sequenceArray
is to use
is that it only returns the first error that it encounters. If there were
multiple processing errors, as is the case in the snippet above, the 2nd and
further errors are discarded.
If we wanted to get an array of errors instead, we could use
array.sequence
.
It gives us more control over the logic done when combining multiple Either
s
in the array. This is specified by its Applicative
parameter. If we only cared
about the first error, we could pass
either.Applicative
as the Applicative
.
Since we care about multiple errors, we need to use a more involved approach -
either.getApplicativeValidation
.
It yields to the Semigroup
that we provide as an argument for the actual logic
that will get run to combine the Left
s from Either
s. The method I reach for
most often is combining them into an array of errors. We can get a Semigroup
for an array of any type by calling
array.getSemigroup
and providing the type we want as a generic parameter.
Let's see how to use it in practice:
// NOTE: prepare the Applicative that can combine `Left`s (`NumberProcessingError`s)
const numberProcessingErrorApplicative = either.getApplicativeValidation(
array.getSemigroup<NumberProcessingError>()
);
pipe(
[1, 2, 3, NaN, 0, 5],
array.map(processNumber),
// NOTE: change the errors to single-element arrays to combine them later
array.map(either.mapLeft((error) => [error])),
// NOTE: compress the `Either<[NumberProcessingError], number>[]`
// into `Either<NumberProcessingError[], number[]>`
array.sequence(numberProcessingErrorApplicative),
either.match(
(errors) => {
console.log("Errors during processing", errors);
},
(numbers) => {
console.log(
"Processing all numbers succeeded. Here are the results:",
numbers
);
}
)
);
Unfortunately, TypeScript playground has problems with type inference in such an involved snippet of code, so no link this time.
We can refactor the code a bit so it is shorter by using
flow
and
array.traverse
:
pipe(
[1, 2, 3, NaN, 0, 5],
- array.map(processNumber),
- // NOTE: change the errors to single-element arrays to combine them later
- array.map(either.mapLeft((error) => [error])),
- // NOTE: compress the `Either<[NumberProcessingError], number>[]`
- // into `Either<NumberProcessingError[], number[]>`
- array.sequence(numberProcessingErrorApplicative),
+ array.traverse(numberProcessingErrorApplicative)(
+ flow(
+ processNumber,
+ either.mapLeft((error) => [error])
+ )
+ ),
either.match(
(errors) => {
console.log("Errors during processing", errors);
},
(numbers) => {
console.log(
"Processing all numbers succeeded. Here are the results:",
numbers
);
}
)
);
When processing an array of results, this is usually enough error handling for me.
Let's see how to combine multiple positional results.
Combining positional results
Sorry, I didn't have a good name for this section.
When I said positional results, I mean a situation in which we execute a few
computations that can fail for different reasons. In such a case, using
either.sequenceArray
or even array.sequence
is usually not enough. Both of
them work with only a single Left
type. They are not good at combining
different error types.
Let's imagine we have the following 3 functions:
import { taskEither } from "fp-ts";
declare function fetchUserById(
clientId: unknown
): taskEither.TaskEither<{ type: "user-not-found-error" }, User>;
declare function fetchSpecialistById(
specialistId: unknown
): taskEither.TaskEither<{ type: "specialist-not-found" }, Specialist>;
declare function fetchWorkingDay(
specialistId: unknown,
startDateTime: unknown
): taskEither.TaskEither<{ type: "working-day-not-found" }, WorkingDay>;
// Stub types so TypeScript does not complain
interface User {}
interface Specialist {}
interface WorkingDay {}
In case you did not know,
TaskEither
is a
container used for asynchronous operations that can fail. It boils down to a
function returning a Promise
that resolves with an Either
to say whether the
result was successful or not. It works around the issue of Promise
rejections
not being strictly typed.
Imagine we want to run these 3 functions concurrently and combine the results. Notice that each function has a different error signature and returns different data.
I said sequenceArray
will not work. Let's see if I am right.
TypeScript complains about error types being different inside of that array.
That makes sense. sequenceArray
needs a single type for an error. It does not
assume it will be a union of error types.
Even if we got taskEither.sequenceArray
to work with the error types, it would
return us an array of a single type. The order would not be preserved.
For situations where the order of results matters, let's use yet another tool in
fp-ts' belt:
apply.sequenceT
.
It requires an Apply
for the container that it will work on. Conveniently,
there is
taskEither.ApplyPar
and
taskEither.ApplySeq
.
Choosing one or the other will make the computations run in parallel or
sequentially.
Let's put that new knowledge into practice:
pipe(
apply.sequenceT(taskEither.ApplyPar)(
fetchUserById(1),
fetchSpecialistById(2),
fetchWorkingDay(2, "2022-07-02")
)
);
Almost. Again, different types of errors cause problems.
Looks like there is really no way around making the errors be of the same type.
pipe(
apply.sequenceT(
taskEither.ApplyPar as apply.Apply2C<
"TaskEither",
{
type:
| "user-not-found-error"
| "specialist-not-found"
| "working-day-not-found";
}
>
)(fetchUserById(1), fetchSpecialistById(2), fetchWorkingDay(2, "2022-07-02")),
taskEither.match(
(error) => {
console.log("Got error", error.type);
},
([user, specialist, workingDay]) => {
// TODO: ...
}
)
);
It works, the error
is strongly typed! But it's not great. We had to manually
combine the error types.
Surely there must be a solution that nudges TypeScript to do that work for us. Also, ideally, we would want an array of errors, not just a single error. Let's see how to do that.
/**
* Converts an error to a array with that error as the only element.
* Necessary to let `getApplicativeValidation` combine errors into an array.
*/
const singletonError = <E, A>(
t: taskEither.TaskEither<E, A>
): taskEither.TaskEither<[E], A> =>
pipe(
t,
taskEither.mapLeft((e) => [e])
);
/** An improved `taskEither.sequenceArray` */
const partitionErrors = <
T extends nonEmptyArray.NonEmptyArray<either.Either<any, any>>
>(
results: T
) => {
type ExtractLeft<T> = T extends either.Left<infer E> ? E : never;
// NOTE: the magic happens here - we extract all the error types
// and tell TypeScript to use that union as the combined error type
type WorkflowError = ExtractLeft<typeof results[number]>[number];
const validation = either.getApplicativeValidation(
array.getSemigroup<WorkflowError>()
);
return apply.sequenceT(validation)(...results);
};
pipe(
// NOTE: we use an `Apply` from `task` so it gives us the raw `Either`s
// instead of the `Right`s
apply.sequenceT(task.ApplyPar)(
singletonError(fetchUserById(1)),
singletonError(fetchSpecialistById(2)),
singletonError(fetchWorkingDay(2, "2022-07-02"))
),
task.map(partitionErrors),
taskEither.match(
(errors) => {
errors.forEach((error) => {
console.log("Got error", error.type);
});
},
([user, specialist, workingDay]) => {
// TODO: ...
}
)
);
See the comments in the code for explanations around some of the new parts.
This is the most advanced error handling solution that I used and needed. It
unwraps positional results so you can process them with strict types as a tuple
(user
, specialist
, workingDay
). As for errors, it infers the union of all
possible error types and returns you an array of those errors, in case there was
at least one error.
With this solution, you can display all errors that happened during these asynchronous operations without having to manually write the union of all error types.
Conclusion
fp-ts
is a great library that makes it easy to write functional code in
TypeScript. Its focus on bringing errors to the forefront and having strict
types for them helps engineers write code that does not ignore errors. An
informed user is a happy user. Even if there was some failure, the user should
be more understanding if we tell them more information behind the failure's
cause.
If we run multiple operations, it is useful to combine their results in a way that forks execution: one branch when there was at least one error, another branch when there were no errors.
There are 2 ways to combine the results. These depend on whether we are working with an array and do not care about the order of results, or we need to get the results in the same order and we know how many results there were.
For the first solution, it is usually enough to use either.sequenceArray
. This
only captures the first error. If we care about all errors, we need to use
apply.sequence
and either.getApplicativeValidation
.
When working with positional arguments, we can also use either.sequenceArray
,
but then we have to specify the combined type that matches all possible errors.
I also presented a solution that infers that type automatically and combines
multiple results into an array.
These 4 methods are enough for me to handle errors and surface them to the users in the code that I write.