Assumed audience: TypeScript developers with an interesting in even safer typed programming with a functional flair.
The main thing right up front: I just released True Myth v8.2.0, which includes a brand new Task type for type safe async code in TypeScript. It’s like a hybrid between Promise and Result, or What Promise always should have been™. You can get it by installing it with your favorite package manager:
| Package manager | Command |
|---|---|
| npm | npm add true-myth@^8.2.0 |
| yarn | yarn add true-myth@^8.2.0 |
| pnpm | pnpm add true-myth@^8.2.0 |
| bun | bun add true-myth@^8.2.0 |
Now for a few more details:
-
When I said it is like a hybrid between a
Promiseand aResult, I meant it. Under the hood,Task<T, E>actually uses aPromise<Result<T, E>>. You never have to work or think about the fact that it is aPromise<Result<T, E>>unless you explicitly ask to do so by callingsomeTask.toPromise(). Instead, we provide nice APIs for interacting with it :map,mapRejected,and,or,andThen,orElse,match, and handy (slightly mind-blowing!) getters to let you introspect synchronously on the state of the async operation. -
This
Tasktype can never throw an error unless you go out of your way to throw an error in the middle of one of its methods. (At which point, uhh… yeah, you threw an error. Why did you do that?) Instead, it actively captures all resolutions asResolvedwith aTvalue and rejections asRejectedwith anEvalue. In other words,Task<T, E>is toPromise<T>for async operations asResult<T, E>is toTfor synchronous operations. -
Unlike the JavaScript/TypeScript
PromiseAPI, we make a clean distinction betweenmapandandThen. ThePromise.prototype.thenmethod supports both mapping and… and-then-ing (binding, flat-mapping, etc.). While this is convenient and “pragmatic”, it also makes it hard to work in a robustly typed way. There is a significant difference between amap-type operation, which by definition does not trigger further asynchrony, and anandThen-type operation, which by definition does. In True Myth’sTask, these are thus distinct concepts. -
Inspired by neverthrow, another excellent library in the same overall space as True Myth,
Taskimplements thePromiseLikeinterface forResult<T, E>(literallyTask<T, E> implements PromiseLike<Result<T, E>>. This means you canawait someTaskand the result is aResult!
If you are a long-time True Myth user, I think you will feel right at home with this library. Working with a Task feels a lot like working with a Result: they share the overall API design and our basic design sensibilities.
For a somewhat real-world example, see this Gist showing how you could combine this new Task type with Zod for a robustly type-safe boundary layer between your app or library and some untrusted data source.
Now, this is very much a first release of Task.
For one thing, the really handy combinators like all, allSettled, or race for combining Tasks are missing. Everything we shipped in v8.2.0 is more or less sequential — ironically enough for a type which emphasizes concurrency!
For another, some of the useful combinators on Result are not present on Task, because we have some open design questions about them. For example: With Result, the unwrapOr method produces a value directly. With Task, by definition it cannot produce a value directly. It can at most only produce a Promise of that value. On the one hand, that might be fine, given you could then await or call then on the Promise and get at the value that way. On the other hand… it is not clear that implementing an unwrapOr which produces a Promise like that actually gets you anything. After all, given you can get a Result by awaiting a Task, you can do this instead:
let theValue = (await someTask).unwrapOr(aDefaultValue);
You can probably see why we have not decided we absolutely must ship something that lets you write this instead:
let theValue = await someTask.unwrapOr(aDefaultValue);
That second one is definitely nicer1 — but it is not that much nicer, and what you cannot see from the syntax sample alone is that it has also given away the guarantee that Task and Result uphold of never having to worry about exceptions. That is a really significant tradeoff.
Finally, there are some important convenience methods we need to make it really easy to work with Task and Result together. For example, the demo code with Task and Zod currently uses an andThen and produces a new Task from the Zod safeParse call, but Zod’s safeParse is synchronous, so that should really use a Result. (Zod has a related async version of safeParse which would make perfect sense to combine with Task.) Right now, that would produce a Task<Result<NestedT, NestedE>, E>, though, which is extremely undesirable ergonomically. We will want to add methods which can flatten that nicely!
None of these will not be especially hard to add or fix, and in fact I already have a first pass done on some of them, but they are not ready to ship — and I really wanted to ship this. Shipping begets shipping. Momentum matters. Getting this version out the door got me excited, and I think it will be that much easier to turn around and get those other features out the door.
Notes
This also highlights why Rust’s choice of postfix keyword for
await— much as I was initially surprised and somewhat turned off by it — was a very good one. In Rust, you would write the first version of that expression, assuming aFuture<Result<T, E>>like this:let the_value = some_task.await.unwrap_or(a_default_value);There are other tradeoffs in play in Rust’s futures vs. TypeScript’s promise API — which I have spent a lot of time thinking about courtesy of literally writing the chapter on async in The Rust Programming Language book. But syntactically, that trailing
.awaitcleans up a lot and I miss it whenever I go back to TypeScript from Rust! ↩︎