This is an implementation of a Future
object for vanilla javascript.
A Future
extends Promise
and partially unwraps itself once it is settled
.
It improves the reusability of Promise
objects, without the need to reassign variables or re-await
it.
The initialization is very similar to a Promise
and has some QoL improvements.
A Future
extends Promise
for 2 reasons:
- to inherit all the
Promise
methods (e.g..then()
or.catch()
), even from future ES specs. - because they are very similar concepts.
Important
Requires ES2022 and ESM imports.
Available to install via npm i @danscode/futures
or deno install npm:@danscode/futures
.
You can grab a test file here.
The accepted function is run immediately, and a Future
is returned.
A Future
accepts both regular and async executor functions.
const futuro = new Future((resolve, reject, signal, ...) => {
/*your code*/
});
const futuroAsincrono = new Future(async (signal, ...) => {
/*your code*/
});
Some handles will be passed to the executor, it depends on the kind of function used as executor:
- These args are always sent, they can be called anything.
- You can pass any extra args to the executor like so
new Future(executor, 0, a, b, c, ...)
. - Regular executor is wrapped in a
Promise
and you can manuallyreject()
orresolve()
it. - Async executor implicitly returns a
Promise
so you cannot manually settle the asyncPromise
.
A Future
accepts an options object:
Property | Description |
---|---|
.signal |
accepts an AbortSignal object (is always passed to executors) |
A Future
has exposed, readonly properties (the object itself remains extensible):
Property | Description |
---|---|
.value |
is the settled value of the underlying Promise |
abort() |
is a reference to the method of an AbortController |
.state |
mimicks internal slot [[PromiseState]] |
.isPending |
returns false for resolved or rejected Future |
value
- initially
null
- if resolved it is the resolved value
- if rejected it is the rejected value
- if thrown it is the error object
abort
- if no
signal
was passed to theFuture
constructor, it is present butundefined
.- this prevents accidental abort of other dependants when you only meant to abort the one
Future
; - also
AbortSignal
has no reference toAbortController.abort()
, so I can't grab it.
- this prevents accidental abort of other dependants when you only meant to abort the one
- once the
Future
is settled, it isnull
(for memory cleanup). - see abort example
The main use case is to achieve some performance gain by avoiding frequent use of await
.
In other words it's like a let data = new Promise()
with more secure automatic unwrapping.
Promise
always has to be unwrapped (even if already resolved) with await
or .then()
otherwise the value is inaccessible.
But await
schedules a microtask just like .then()
.
This means every await
surrenders this iteration of the event loop, because a microtask
is only executed between iterations of the event loop.
Also an async
function will be frozen untill it resolves its first await
and then the next, and the next...
This is an example of a busy program with the hot path in red:
The following diagram implies that promises might also call/contain long tasks
%% if you see this text try https://mermaid.live/
flowchart LR
NAME["Event Loop (simplified)"]
stack@{ shape: lin-cyl, label: "Check Call Stack" }
stack--- checkT{"Has a task"}
checkT--- |Yes| busyT@{shape: processes, label: "Execute code..."}
checkT--- |No| mTstack@{ shape: lin-cyl, label: "Check Microtask Queue" }
busyT-- "Build the stack up and down" ---stack
mTstack--- checkmT{"Has a microtask"}-.-x |Yes| busyT
%% Highlight the edges in the hot path
linkStyle 0,1,3 stroke:#ff1f1f,stroke-width:5px
%% cold path
linkStyle 2,4 stroke:#3464ff,stroke-width:2px
As you can see, every time you have to await
, your code takes a passing loop.
It will only execute after the entire call stack has been emptied.
There is one trick to await
the value at the very last moment, letting the function start other Promise
s and complete some synchronous setup.
async function longTask() {
const file1 = fileAsync('bunny.png');
const file2 = fileAsync('carrot.png');
let number = numberAsync(1);
// long setup
for (let i = 0; i < 100000; i++) {
const x = i * 3;
}
// actual use of data
number = await number;
return (await file1) + (await file2) + number;
}
I find several problems with this approach, specifically in more complicated production grade code:
- We still can only get the value out by
awaiting
again.- what if we need to use
const file1
more than once?
- what if we need to use
- We could reassign
let number
to the onceawaited
value.- very easy to reassign it somewhere else by accident.
- We could start all
Promise
s withPromise.all()
which still has to be unwrapped with anawait
.- and possibly destructured into more variables;
- what if there are more
Promise
s dependent on earlierPromises
? - what if
Promises
are best awaited at different points in the function?
Future
removes the need for tricks, you only need to await
it once to receive the value.
Any later access is direct and synchronous via <future>.value
.
A Future
also integrates with AbortSignal
in two ways:
- You can pass an
AbortController.signal
object in theFuture
constructor options. - Or an
AbortController
will be created.- this makes sure the
AbortSignal
is always passed into executors; - and exposes the
.abort()
method on theFuture
itself.
- this makes sure the
Here is an example of how to use it:
const cancelledFuture = new Future((res, rej, signal) => {
signal.addEventListener(
'abort',
() => {
rej(signal.reason);
},
{ once: true }
);
// never resolves
setTimeout(() => {
res(200);
}, 1500);
});
try {
cancelledFuture.abort(`I don't want this`);
console.log(await cancelledFuture);
} catch (e) {
console.log(e);
}
Dan: Code author
1.1.2 -> 2.0.0
- improved natural async function detection
- changed
<future>.v
to<future>.value