tl;dr
TaskEither can enforce type-safe error handling. Always convert with tryCatch. Laziness is a mixed blessing. Always return Promise<void> when invoking Task.
Contents
- Two Problems with Promise
- Welcome to Task
- Task Rules
- Why are there rules this is dumb
- Should I use Task
- Emotional component
- Conclusion
Two Problems with Promise
Here's an example of a promise using fetch:
fetch('https://jsonplaceholder.typicode.com/todos/1')
.then(response => response.json())
.then(json => console.log(json))
.catch((err: any) => {
console.error(error.message)
})
catch uses any
We can do this without a compile-time error.
fetch('https://jsonplaceholder.typicode.com/todos/1')
.then(response => response.json())
.then(json => console.log(json))
.catch((err: any) => {
console.error(error.mesage) // 'message' is misspelled
})
catch is optional
This throws a 'rejected Promise not handled' runtime exception.
fetch('http://jsonplaceholder.net/todos/-1') // wrong url
.then(response => response.json())
.then(json => console.log(json))
This is Bad
We never know whether any given Promise could fail, or how it might fail. Anything could go wrong at any time. It's hard to overstate how much damage this can cause. Given that asynchronous operations are often an application's biggest source of potential errors, untyped & optional asynchronous error handling is a recipe for disaster.
Welcome to Task
Here's the definition for Task1:
export type Task<A> = () => Promise<A>
How does this help us? All we're doing is returning a Promise from an anonymous function.
It's not obvious, but Task will solve both of the problems listed above - it'll allow us to enforce consistent error handling at compile time.
However! It doesn't work unless you play by the rules. There are three Task rules that you must never violate under penalty of bugs.
Task Rules
Rule Number 1
You must always convert Promise to Task using tryCatch.
const safeAsync: Task<Either<Error, Response>> = tryCatch(
(): Promise<Response> => fetch('https://jsonplaceholder.typicode.com/todos/1'),
(err: unknown): Error => {
if (err instanceof Error) {
return err
}
return new Error(String(err))
}
)
The first parameter is a function that returns a Promise. I'll explain later why this is a function and not just a Promise.
The second parameter forces us to catch the errors from the Promise from the first parameter. The potential error is represented as an unknown instead of an any.
unknown is safer than any because unknown forces us to narrow the type. In this case, we're narrowing with instanceof.
Check out that return type. Our Task will resolve into a value called an Either. An Either value is either an error value or a success value, but never both. We call the error value Left and the success value Right by convention. Here's the definition of Either:
interface Left<E> {
_tag: 'Left'
left: E
}
interface Right<A> {
_tag: 'Right'
right: A
}
type Either<E, A> = Left<E> | Right<A>
Let's look again at our return value:
Task<Either<Error, Response>>
This means that our Task will return either an Error or a Response (fetch returns a type called Response).
Compare this with the return type of fetch:
Promise<Response>
We can see that fetch will return a Response, but we have no idea what its error type might be. Could it even have an error? Task<Either<Error, Response>> tells us more about what's going on, and holds us to a contract - we must handle both possibilities.
The combination of Task and Either is so common, it has a type alias called TaskEither2:
type TaskEither<E, A> = Task<Either<E, A>>
The TaskEither package is actually where we import tryCatch from:
import * as E from 'fp-ts/Either'
import * as TE from 'fp-ts/TaskEither'
const safeAsync: TE.TaskEither<Error, Response> = TE.tryCatch(
(): Promise<Response> => fetch('https://jsonplaceholder.typicode.com/todos/1'),
E.toError,
)
We also have a convenience function called toError from the Either package that converts unknown into Error for us.
However, we still have to convert our Response into a JSON value3:
import { pipe } from 'fp-ts/pipeable'
const safeAsync: TE.TaskEither<Error, any> = pipe(
TE.tryCatch(
(): Promise<Response> => fetch('https://jsonplaceholder.typicode.com/todos/1'),
E.toError,
),
TE.chain(response => TE.tryCatch(
(): Promise<any> => response.json(),
E.toError,
)),
)
If you're unfamiliar with pipe syntax, I recommend Ryan Lee's excellent Practical Guide to fp-ts.
TE.chain is similar to Promise.prototype.then.
We must wrap response.json() in a tryCatch because it's a Promise.
How will we print the result?
import * as T from 'fp-ts/Task'
const safeAsync: T.Task<void> = pipe(
TE.tryCatch(
(): Promise<Response> => fetch('https://jsonplaceholder.typicode.com/todos/1'),
E.toError,
),
TE.chain(response => TE.tryCatch(
(): Promise<any> => response.json(),
E.toError,
)),
T.map(E.fold(
console.error,
console.log,
)),
)
T.map is also like Promise.prototype.then. While T.chain means what we must return a Task, T.map means that we can return anything we like. In this case, we return void
E.fold lets us do something for both the error case and the success case. It's a bit like using both callbacks of then, except the callbacks' return types have to match. In this example, we print simply print them both out to the console, with appropriate error styling.
Why is it a rule that we have to use tryCatch? Because it's possible to create a Task like this:
const unsafeAsync: Task<Response> = () => fetch('https://jsonplaceholder.typicode.com/todos/1')
However, this obviously doesn't solve our type-safety problem. What type of error could this cause? This might as well be a plain Promise.
Task is only useful if it can never fail.
We do this by representing both failure and success in its return type through Either.
Rule Number 2
You must explicitly type an invoked Task as Promise
Wait, what does 'invoke' mean?
Well it turns out that this will never run:
const safeAsync: T.Task<void> = ...
Until you invoke it like this:
// invoking the task
safeAsync()
Let's go back to our definition of a Task:
export type Task<A> = () => Promise<A>
Since a Task is actually a function, "invoking" simply means calling the function.
Wait, when is a Promise run anyway? Which of these Promises will be run?
const a: Promise<void> = new Promise(() => console.log('running a'))
new Promise(() => console.log('running b'))
function later() {
return new Promise(() => console.log('running c'))
}
// output:
// running a
// running b
Promises are 'eagerly' evaluated, meaning that they're run as soon as they're constructed. The Promise returned by the function later has not yet been constructed because later has not been called yet.
later represents a 'lazy' evaluation, meaning that the Promise will not be run until later is called.
Since Task is a function, it represents a 'lazy' evaluation of its return value4. In fact, later could be typed as a Task
const later: T.Task<void> = () => {
return new Promise(() => console.log('running c'))
}
Why do this? Well, the main benefit is that it can be easier to reason about what's actually happening. I'll quote an article about Scala (warning: paywall) that talks about this:
"With [Promise], our code acts differently according to where we write our code...A [Promise] doesn’t describe an execution, it executes."
By contrast, Task describes an execution rather than executing it. This means that we can manipulate a Task with chain and map as much as we want and pass it around the program secure in the knowledge that it hasn't been invoked yet. We can also invoke the same Task multiple times.
The problem is that sometimes, we forget to invoke Task! We can solve this by following rule # 2:
const onClick = (): Promise<void> => ...
We have explicitly typed onClick so that it must return a Promise. If we forget to return a promise, we'll get a compile-time error:
const onClick = (): Promise<void> => {
const safeAsync: T.Task<void> = ...
// this won't compile:
// return safeAsync
//
// this will:
// return safeAsync()
}
I actually prefer to write it this way because I think it's cleaner:
const safeAsync: T.Task<void> = ...
const onClick = (): Promise<void> => pipe(
safeAsync,
invokeTask => invokeTask(),
)
Rule Number 3
You may only ever invoke Task<void>. You can never invoke Task<string> or Task<number> etc.
You should especially avoid invoking a TaskEither.
This forces you to handle all of your cases before you run them.
const safeAsync: TE.TaskEither<Error, any> = ...
const onlyLogCorrect: TE.TaskEither<Error, void> = pipe(
safeAsync,
TE.map(console.log),
)
onlyLogCorrect() // we never handled the error case!
const logAll: T.Task<void> = pipe(
safeAsync,
T.map(E.fold(
console.error,
console.log,
))
)
logAll() // we handled all possibilities
We can follow rule #2 and rule #3 by returning Promise<void> whenever we invoke a Task.
const logAll: Promise<void> = pipe(
safeAsync,
T.map(E.fold(
console.error,
console.log,
)),
invokeTask => invokeTask(),
)
Why are there rules this is dumb
We could come up with a similar set of rules that would make Promise safer to use as well. Why go through the trouble of converting everything to Task and back again?
As I said earlier, the advantage is type-safety. Promise sacrifices a lot of safety by using any to represent error values. By converting to Task and back, we hold ourselves to the contract of whichever type we decide our error should be.
For even more power and specificity, you can use a union type or sum type to represent all of your different possible errors. This can strengthen a project that handles more than one possible kind of error (probably most projects). If this sounds interesting, check out my next article, Even More Beautiful API Calls with Sum Types
I'll tell you a secret - pure functional programmers don't have to follow rules #2 and #3. They have a rule of their own - they can never invoke Task at all! Sounds impossible, right? If you're curious about this and you have some time on your hands, check out my relevant article Why is fp-ts TaskEither Like that? - TaskEither vs Fluture
Should I use Task
As with Option, I advocate always using Task as much as possible if the project is new and you are building it from the ground up.
On the flip side, if a project is old and has lots of Promises in it already, it's probably best to store Promises in state and return them from functions, but Task can still be useful behind the scenes.
Although the main benefit is error handling, there's also a small benefit of operator readability. Promise.prototype.then can return either a Promise or some other generic value. The map and chain operators are separate - chain is for returning a Task, while map is for some other generic value. fold is also more specific than then - both callbacks must must return the same type. This makes your code easier to read: you can tell a lot about what's happening simply by reading the name of each operator.
Emotional component
It can feel oppressive at first to use compiler-enforced error handling. Error cases can pile up quickly and it might not be obvious what to do about all of them, or which ones are ok to ignore. On the flip side, TaskEither can help you discover errors you hadn't realized were possible. It can be demoralizing to realize that you had been letting errors slip through the cracks.
It can be difficult to have conversations about potential errors with product engineers/ux designers. Although it may seem like a trivial workplace conversation, sometimes it can feel like admitting defeat or failure, or worse, an accusation, to tell them that the product can fail in a way neither of you had expected. It's worth having some self-compassion in those moments.
Or maybe you're your own product person because you're working for a small company, or you're working on a solo project, or things just worked out that way. If that's the case and you find yourself overwhelmed by Task, it might be helpful to research some UX patterns for inspiration:
- https://mobilejazz.com/blog/how-to-handle-errors-properly-ux/
- http://www.userjourneys.com/blog/ux-guidelines-for-error-handling/
- https://uxdesign.cc/creating-error-messages-best-practice-in-ux-design-cda3be0f5e16
- https://medium.com/@m.fomenko/handling-error-codes-right-a-how-to-for-product-managers-6c99a6b9bc3b
It's important to have these conversations so you can recognize which errors are worth keeping track of separately, and which can be smushed together and handled the same way. Maybe most problems can be handled with a simple error popup. This is the advantage of using sum types, which you can read about in the article I mentioned earlier. Sum types can relieve a lot of the burden. The idea is to set what granularity you need early on and to hold yourself to that as you go, modifying when necessary.
Conclusion
Task can be cumbersome compared to Promise - conversion is a pain, you have to remember to invoke it, and you have to remember to handle all of its cases.
However, the benefit is a dramatic decrease in bugs and an increase in readability. I think this makes Task a no-brainer for most projects, whether it's completely integrated, or only used sparingly.
The rules above can help you use Task to its fullest advantage and without fear. Remember:
- Always convert
PromisetoTaskusingtryCatch - Explicitly type an invoked
TaskasPromise - Only ever invoke
Task<void>
I hope Task leads you toward a bright future of asynchronous safety and security!
-
Taskis actually defined as aninterfacewhich is different but weirder looking and basically the same ↩ -
TaskEitheris also defined as aninterface↩ -
You can get rid of the
anytype in this code using io-ts. io-ts usesEitherfor its errors too - it's a match made in heaven! Here's a great tutorial: fp-ts and Beautiful API Calls ↩ -
Returning a value from a parameterless function to make it lazily evaluated is a common pattern called a 'thunk'. In this example, we would say that
lateris a thunk. According to Eric Raymond: ↩[The term] was coined after they realized [...] that the type of an argument in Algol-60 could be figured out in advance with a little compile-time thought [...] In other words, it had 'already been thought of'; thus it was christened a thunk, which is 'the past tense of "think"'
This refers to the fact that the function has no parameters. Stephen Huig:
A zero-argument function has no way to change its behavior based on parameters it is called with, since it has no parameters. Therefore the entire operation of the function is set -- it is just waiting to be executed. No more "thought" is required on the part of the computer, all of the "thinking" has been done -- the action is completely "thunk" through.

Top comments (2)
Very nice explanation. It helped me a lot.
Great article