In #31 we changed tryCatch for taskEither to:
declare function tryCatch<A, B>(task: Task<A>, onThrow: (e: unknown) => B): TaskEither<B, A>;
While working on #39 I finally remembered why I was against this change. The simple answer is that Task
is never supposed to throw. None of the Task combinators handle the thrown side of the returned promise, which makes it exactly the wrong vehicle to wrap promise thunks that can throw. In actuality, a Task that can fail is no Task at all. While there may be functions in typescript with the type () => Promise<A>
, if these functions can throw or their promises can throw then they shouldn't be Tasks.
First, let's disambiguate the various cases where we handle untyped thrown errors:
- Handling synchronous functions that might throw
type Function<AS extends unknown[], R> = (...as: AS) => R
- Handling asyncronous functions that might throw (note that here there can be both synchronous and asynchronous throwing)
type AsyncFunction<AS extends unknown[], R> = (...as: AS) => Promise<R>
We will want to define tryCatch-like functions for many of the ADTs in fun. Following is a list of the ADTs in fun where it makes sense to implement tryCatch. ADTs that should also handle async tryCatch will be marked with tryPromise
:
- Affect: tryCatch, tryPromise
- Datum: tryCatch
- Either: tryCatch
- IO: tryCatch
- IOEither: tryCatch
- Nilable: tryCatch
- Reader: tryCatch
- ReaderEither: tryCatch
- Task: tryCatch*, tryPromise
- TaskEither: tryCatch*, tryPromise
- These: tryCatch
Here, a * denotes that the non-thunk versions of tryCatch can cause some issues. Lets look at those (Task, TaskEither).
When implementing tryCatch for Task there is a natural implementation:
function taskTryCatch1<A>(fa: () => A, onThrow: (e: unknown) => A): Task<A> {
return () => {
try {
return fa();
} catch (e) {
return onThrow(e);
}
}
}
But what if we want to turn a non-thunk into a Task? Then we might have:
function taskTryCatch2<AS extends unknown[], R>(
fasr: (...as: AS) => R,
onThrow: (e: unknown) => R
): (...as: AS) => Task<R> {
return (...as) => () => {
try {
return fasr(...as);
} catch (e) {
return onThrow(e);
}
}
}
Both of these are valid cases, but the second one covers the usage of the first as well, but ends up returning Task<Task<R>>
when the function is a thunk. These cases repeat for promise returning functions:
function taskTryPromise1<A>(fa: () => Promise<A>, onThrow: (e: unknown) => A): Task<A> {
return async () => {
try {
return await fa();
} catch (e) {
return onThrow(e);
}
}
}
function taskTryPromise2<AS extends unknown[], R>(
fasr: (...as: AS) => Promise<R>,
onThrow: (e: unknown) => R
): (...as: AS) => Task<R> {
return (...as) => async () => {
try {
return await fasr(...as);
} catch (e) {
return onThrow(e);
}
}
}
After writing these implementations and many others I realized why this problem was likely seeming more complicated than it is. The example used as the reason for this change was this:
export const old_get_database_by_id = (database_id: string) =>
pipe(
() => databases.retrieve({ database_id }),
TE.fromFailableTask(() => new Error(`Couldnt fetch db: ${database_id}`))
);
export const new_get_database_by_id = (database_id: string) =>
tryCatch(
() => databases.retrieve({ database_id }),
() => new Error(`Couldnt fetch db: ${database_id}`)
);
Here the old way was problematic because it was trying to use tryCatch (which only accepted thunks) to map a promise returning function to TaskEither. If we had restated the question to: What is the most sensible way to take functions of the form type Input<AS extends unknown[], R> = (...as: AS) => Promise<R>
and turn them into ADTs like Task, TaskEither, and Affect? Well, lets list the things we want tryCatch to do:
- It needs to wrap more than just thunks.
- It needs to catch errors--both synchronous and asynchronous.
- It should be consistent across ADTs.
- The error case should have access to the the arguments of the throwing function/promise.
Because of 1 I propose that idiomatic tryCatch shouldn't default to thunks. This means we would move away from taskTryCatch1
style implementations and towards taskTryCatch2
.
Because of 2 I propose that we break tryCatch into a synchronous tryCatch version and an async tryPromise version. This is to avoid testing for Promises in both the result and throw cases of tryCatch.
Because of 4 the onThrow case should accept both the error and the arguments array. Here we have the choice of 1 (e: unknown, ...as: AS)
or 2 (e: unknown, as: AS)
. For consistency reasons (and to cut down on an extra spread operation) I am tentatively going with 2.
This makes our implentations for Task and TaskEither as follows:
export function taskTryCatch<AS extends unknown[], R>(
fasr: (...as: AS) => R,
onThrow: (e: unknown, as: AS) => R,
): (...as: AS) => Task<R> {
return (...as) =>
handleThrow(
() => fasr(...as),
(r) => Promise.resolve(r),
(e) => Promise.resolve(onThrow(e, as)),
);
}
export function taskTryPromise<AS extends unknown[], R>(
fasr: (...as: AS) => Promise<R>,
onThrow: (e: unknown, as: AS) => R,
): (...as: AS) => Task<R> {
return (...as) =>
handleThrow(
() => fasr(...as),
(r) => r.catch((e) => onThrow(e, as)),
(e) => Promise.resolve(onThrow(e, as)),
);
}
export function taskEitherTryCatch<AS extends unknown[], A, B>(
fasr: (...as: AS) => A,
onThrow: (e: unknown, as: AS) => B,
): (...as: AS) => TaskEither<B, A> {
return (...as) =>
handleThrow(
() => fasr(...as),
(r) => Promise.resolve(right(r)),
(e) => Promise.resolve(left(onThrow(e, as))),
);
}
export function taskEitherTryPromise<AS extends unknown[], A, B>(
fasr: (...as: AS) => Promise<A>,
onThrow: (e: unknown, as: AS) => B,
): (...as: AS) => TaskEither<B, A> {
return (...as) => {
const _onThrow = (e: unknown) => left(onThrow(e, as));
return handleThrow(
() => fasr(...as),
(r) => r.then(right).catch(_onThrow),
(e) => Promise.resolve(_onThrow(e)),
);
};
}
and the usage of the original case looks something like this:
export const get_database_by_id = tryPromise(
databases.retrieve,
(_, { database_id }) => new Error(`Couldnt fetch db: ${database_id}`)
);
// Which returns the type:
type GetDatabaseReturn = (props: DBProps) => TaskEither<Error, DBReturnValue>