bastianplsfix

Promises

A promise is a container for a result that does not exist yet. It is pending until it either fulfils with a value or rejects with a reason. Those are the only three states, it settles at most once, and it remembers what it settled with, so a handler registered afterwards still runs. You read a settled promise by registering callbacks: .then for a value, .catch for a reason, .finally for either, and both .then and .catch return promises, which is what makes chaining work.

Most of the time you will not write any of that, because the async functions page gives you await instead, and the tests below already use it as the plain way to read an outcome. This entry is still worth working through, for three reasons: await is defined in terms of these rules, the functions you call return these objects, and one of the rules will otherwise end your process without explanation.

Create programs/promises.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import {
assert,
assertEquals,
assertRejects,
assertStrictEquals,
assertThrows,
} from "@std/assert";

Follow the page as you add and revise the runnable examples below that import.

making one, and reading it

Deno.test("making one, and reading it", async () => {
function divideAsync(x: number, y: number): Promise<number> {
return new Promise((resolve, reject) => {
if (y === 0) {
reject(new Error("cannot divide by zero"));
} else {
resolve(x / y);
}
});
}

assertStrictEquals(await divideAsync(28, 4), 7);

const error = await assertRejects(() => divideAsync(28, 0), Error);
assertStrictEquals(error.message, "cannot divide by zero");
});
Check programs/promises.test.ts
running 1 test from ./programs/promises.test.ts
making one, and reading it ... ok (338µs)

ok | 1 passed | 0 failed (1ms)

The function passed to new Promise is the executor, and it receives two functions: call resolve to succeed, reject to fail. It runs immediately, and whichever is called first decides the outcome. Note who gets those two functions: only the code that built the promise. Anyone you hand the promise to can read it and cannot settle it, which is deliberate, and it is why a promise is safe to give away. assertRejects is the rejection-side reading tool, awaiting the promise and handing back the reason it rejected with.

then, catch, and finally

Deno.test("then, catch, and finally", async () => {
const log: string[] = [];

const value = await Promise.resolve(7)
.then((sum) => {
log.push(`then ${sum}`);
return sum * 2;
})
.catch(() => {
log.push("catch");
return 0;
})
.finally(() => {
log.push("finally");
});

assertStrictEquals(value, 14);
assertEquals(log, ["then 7", "finally"]);
});
then, catch, and finally ... ok (191µs)

.then runs on fulfilment, .catch on rejection, .finally on either, and each returns a new promise, so the value that comes out of the chain is whatever the last callback produced: fourteen, from the then, with the catch skipped entirely. .catch(f) is exactly .then(undefined, f), worth knowing because you will meet the two-argument form in older code, and once more further down this page.

a promise settles once

Three settlement calls in one executor. Predict the awaited value:

Deno.test("a promise settles once", async () => {
const settled = new Promise<string>((resolve, reject) => {
resolve("first");
resolve("second");
reject(new Error("also ignored"));
});

assertStrictEquals(await settled, "second");
});
Check programs/promises.test.ts
running 3 tests from ./programs/promises.test.ts
...
a promise settles once ... FAILED (8ms)

ERRORS

a promise settles once => ./programs/promises.test.ts:48:6
error: AssertionError: Values are not strictly equal.

[Diff] Actual / Expected

- first
+ second

FAILURES

a promise settles once => ./programs/promises.test.ts:48:6

FAILED | 2 passed | 1 failed (10ms)

error: Test failed

The first call wins and the rest are silently ignored: no error, no warning, and the reject after it did nothing either, or the await would have thrown instead of producing a value. That is a guarantee to build on, because once you have seen a promise's outcome it will not change. It is also why an executor with two paths does not strictly need an else, though writing one is clearer. Correct the prediction to "first":

a promise settles once ... ok (97µs)

the result is cached, so a late handler still runs

Deno.test("the result is cached, so a late handler still runs", async () => {
const already = Promise.resolve("value");

await new Promise((resolve) => setTimeout(resolve, 0));

assertStrictEquals(await already, "value");
assertStrictEquals(await already.then((v) => v.toUpperCase()), "VALUE");
});
the result is cached, so a late handler still runs ... ok (5ms)

A promise holds its result rather than announcing it, so attaching a handler a whole task later works, and you can attach as many as you like at any time. This is the difference from an event: an event that fires before you are listening is gone, where a promise's settlement waits for you, which is why promise-based code has none of the setup-order problems that callback and event APIs are full of. One important exception, and it is the process-ending step below: the waiting is true for fulfilment, and true for rejection only until the runtime decides nobody is going to handle it.

what a then callback returns decides the next promise

Deno.test("what a then callback returns decides the next promise", async () => {
assertStrictEquals(await Promise.resolve("oak").then((s) => s + s), "oakoak");

assertStrictEquals(
await Promise.resolve("oak").then(() => Promise.resolve(123)),
123,
);

const error = await assertRejects(
() =>
Promise.resolve("oak").then(() => {
throw new Error("from a callback");
}),
Error,
);
assertStrictEquals(error.message, "from a callback");

const recovered = await Promise.reject(new Error("failed"))
.catch(() => "default");
assertStrictEquals(recovered, "default");
});
what a then callback returns decides the next promise ... ok (87µs)

Three rules, and they are the whole reason a chain works. Return a value and the next promise fulfils with it. Return a promise and the next promise adopts it, so the second pin produces the eventual 123 rather than a promise wrapped in a promise, and this is the rule that matters most in practice: it is why a chain stays flat, since returning a promise from a callback and continuing in the next .then does the same job as nesting, and reads better. Throw and the next promise rejects, which is why a single .catch at the end of a chain sees everything, because a synchronous exception in any callback becomes a rejection that flows down. And .catch follows the same three rules, so it can recover: returning a value from it produces a fulfilled promise, and everything after that point sees a success. That is either exactly what you want or a swallowed error, and nothing distinguishes them but intent.

resolving is not fulfilling

resolve is called unconditionally, so predict the outcome:

Deno.test("resolving is not fulfilling", async () => {
const inner = Promise.reject(new Error("inner failed"));

const outer = new Promise<number>((resolve) => {
resolve(inner);
});

const outcome = await outer.then(
() => "fulfilled",
() => "rejected",
);
assertStrictEquals(outcome, "fulfilled");
});
Check programs/promises.test.ts
running 6 tests from ./programs/promises.test.ts
...
resolving is not fulfilling ... FAILED (6ms)

ERRORS

resolving is not fulfilling => ./programs/promises.test.ts:92:6
error: AssertionError: Values are not strictly equal.

[Diff] Actual / Expected

- rejected
+ fulfilled

FAILURES

resolving is not fulfilling => ./programs/promises.test.ts:92:6

FAILED | 5 passed | 1 failed (12ms)

error: Test failed

The two words are not synonyms, and this is the one piece of promise vocabulary worth being precise about. Fulfilling a promise gives it a value. Resolving a promise decides where its outcome comes from: resolve with an ordinary value and it fulfils with that value, resolve with a promise and yours adopts that promise's fate, pending while it is pending, fulfilled if it fulfils, and rejected when it rejects, as here. So the parameter named resolve is honestly named, and calling it does not promise success. Correct the prediction, and pin the same rule in a place you can see it:

Deno.test("resolving is not fulfilling", async () => {
const inner = Promise.reject(new Error("inner failed"));

const outer = new Promise<number>((resolve) => {
resolve(inner);
});

const outcome = await outer.then(
() => "fulfilled",
(reason: Error) => `rejected: ${reason.message}`,
);
assertStrictEquals(outcome, "rejected: inner failed");

const existing = Promise.resolve("oak");
assertStrictEquals(Promise.resolve(existing), existing);
});
resolving is not fulfilling ... ok (53µs)

Promise.resolve given a promise hands back that very promise rather than a copy, which makes it the standard way to accept "a value or a promise" and end up with a promise either way, at no cost when it already was one.

finally passes the settlement through, unless it fails

Deno.test("finally passes the settlement through, unless it fails", async () => {
assertStrictEquals(await Promise.resolve(123).finally(() => {}), 123);

const passed = await assertRejects(
() => Promise.reject(new Error("original")).finally(() => {}),
Error,
);
assertStrictEquals(passed.message, "original");

const replaced = await assertRejects(
() =>
Promise.reject(new Error("original")).finally(() => {
throw new Error("from finally");
}),
Error,
);
assertStrictEquals(replaced.message, "from finally");
});
finally passes the settlement through, unless it fails ... ok (68µs)

.finally is for cleanup, so it is deliberately hard to influence the result with it: whatever the callback returns is ignored, and the settlement that arrived is the settlement that leaves, as the first two pins show. The third pin is the way to break that, and it loses information. A .finally callback that throws, or that returns a rejected promise, replaces the settlement, and the original error is gone. This is the same hazard as a finally block that throws, from the errors and exceptions page's finally can discard the exception, and the same advice: keep cleanup code incapable of failing, or wrap it.

a rejection nobody handles ends the process

So far every rejection was read promptly. Put one nobody reads in a scratch file programs/unhandled.ts, with a rescue attempt scheduled for the next task:

const failing = Promise.reject(new Error("nobody is listening"));

console.log("the rest of this task still runs");

setTimeout(() => failing.catch(() => console.log("caught, too late")), 0);

Run it with deno run programs/unhandled.ts:

the rest of this task still runs
error: Uncaught (in promise) Error: nobody is listening
const failing = Promise.reject(new Error("nobody is listening"));
^
at file:///programs/unhandled.ts:1:32

Read the output carefully, because two things in it matter. The process dies: Deno treats an unhandled rejection as a fatal error and exits with status 1, where a browser would log it and carry on, so a rejection you forgot to handle is not a warning in your logs but the end of your program, with a stack trace pointing at where the promise was created rather than where it was ignored. And the catch in the timer never ran, because "caught, too late" is absent. That contradicts the reassurance of the cached-result step, and the resolution is precise: the result does wait, but the runtime decides whether a rejection is handled at the end of the current task, once the microtask queue has drained, machinery the event loop page lays out. A handler attached in a later task is too late, and by then the process is already going down. The rule that follows is short: attach the handler in the same task that creates the promise. Delete the scratch file before it takes the test suite with it.

do not mix rejections with exceptions

Deno.test("do not mix rejections with exceptions", async () => {
function mightThrow(n: number): number {
if (n < 0) throw new RangeError("negative");
return n;
}

function doubleAsync(n: number): Promise<number> {
return Promise.resolve(n * 2);
}

function asyncFunc(n: number): Promise<number> {
return doubleAsync(mightThrow(n));
}

assertThrows(() => asyncFunc(-1), RangeError, "negative");

assertStrictEquals(await Promise.try(() => doubleAsync(mightThrow(4))), 8);

const error = await assertRejects(
() => Promise.try(() => doubleAsync(mightThrow(-1))),
RangeError,
);
assertStrictEquals(error.message, "negative");

assertStrictEquals(
await Promise.try((a: number, b: number) => a + b, 1, 2),
3,
);
});
do not mix rejections with exceptions ... ok (220µs)

A promise-returning function should report every failure as a rejection and never throw, so callers write one error path, and a caller who wrote only .catch does not get an exception through the front door. It is easy to get wrong by accident, because synchronous work before the first promise throws synchronously: asyncFunc is that shape, and the first pin needing assertThrows rather than assertRejects is the evidence. Promise.try(callback) exists for exactly this. It calls the callback immediately and treats it as .then treats one, so a returned value resolves, a returned promise is adopted, and a thrown exception becomes a rejection, meaning a chain that starts with something fallible starts safely. It also forwards trailing arguments, which saves a closure. Older code solves the same problem with Promise.resolve().then(() => ...), which works and runs the callback one microtask later rather than immediately.

an async executor is a lint error, and a real bug

One more way to lose a rejection, and the linter refuses it outright. Write an executor marked async, in a scratch file programs/async-executor.ts:

export const swallowed = new Promise<number>(async (resolve) => {
const value = await Promise.resolve(1);
if (value !== 2) {
throw new Error("this rejection goes nowhere");
}
resolve(value);
});

deno lint has a rule for exactly this:

error[no-async-promise-executor]: Async promise executors are not allowed
--> programs/async-executor.ts:1:26
|
> 1 | export const swallowed = new Promise<number>(async (resolve) => {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 2 | const value = await Promise.resolve(1);
...
> 6 | resolve(value);
> 7 | });
| ^^
= hint: Remove `async` from executor function and adjust promise code as needed

docs: https://docs.deno.com/lint/rules/no-async-promise-executor


Found 1 problem
Checked 1 file

The linter is right, and the reason is worth understanding. An async function turns a throw into a rejection of its own promise, and nobody is holding that promise, because new Promise ignores whatever the executor returns. So the error above does not reject swallowed, which stays pending forever, and the rejection lands on a promise you cannot see, which the previous step showed is fatal. If the work is already async you do not need new Promise at all: that is the creating-promises-instead-of-chaining mistake, and the fix is to return the chain. Delete the scratch file.

the body starts now, the settlement arrives later

starting is a promise-returning function. Predict where "body" and "executor" land relative to "after":

Deno.test("the body starts now, the settlement arrives later", async () => {
const log: string[] = [];

function starting(): Promise<void> {
log.push("body");
return new Promise((resolve) => {
log.push("executor");
resolve();
});
}

log.push("before");
const pending = starting().then(() => {
log.push("settled");
});
log.push("after");

await pending;
assertEquals(log, ["before", "after", "body", "executor", "settled"]);
});
Check programs/promises.test.ts
running 9 tests from ./programs/promises.test.ts
...
the body starts now, the settlement arrives later ... FAILED (5ms)

ERRORS

the body starts now, the settlement arrives later => ./programs/promises.test.ts:161:6
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

[
"before",
+ "after",
"body",
"executor",
- "after",
"settled",
]

FAILURES

the body starts now, the settlement arrives later => ./programs/promises.test.ts:161:6

FAILED | 8 passed | 1 failed (14ms)

error: Test failed

Both ran before starting() even returned. A promise-returning function's body runs synchronously when you call it, and its promise settles in a later microtask at the earliest, which is where "settled" lands. Both halves are useful. Because bodies start synchronously, the order in which you call promise-returning functions is the order in which their work begins, which is what makes the concurrency reasoning on the promise combinators page work. Because settlements are always asynchronous, a promise-based function never returns its result down the same path twice, so callers have one code path rather than two. Correct the prediction:

the body starts now, the settlement arrives later ... ok (51µs)

a thenable is enough

Deno.test("a thenable is enough", async () => {
const thenable = {
then(onFulfilled: (value: string) => void) {
onFulfilled("from a thenable");
},
};

assertStrictEquals(await thenable, "from a thenable");
assertStrictEquals(await Promise.resolve(thenable), "from a thenable");
assert(Promise.resolve(thenable) instanceof Promise);
});
a thenable is enough ... ok (32µs)

Nothing in the API requires an actual Promise. An object with a then method is a thenable, and await, Promise.resolve, and every adoption rule above accept one, converting it to a real promise transparently, as the instanceof pin shows. This exists because several promise libraries predated the built-in one and had to keep working. You will not write a thenable, and knowing the term explains why await works on things from libraries that never mention Promise.

a gate, and one flat chain

Deno.test("a gate, and one flat chain", async () => {
const { promise, resolve } = Promise.withResolvers<string>();

let opened = false;
const waiting = promise.then((signal) => {
opened = true;
return `gate: ${signal}`;
});

assertStrictEquals(opened, false);
resolve("go");
assertStrictEquals(await waiting, "gate: go");
assertStrictEquals(opened, true);

function readConfig(): Promise<string> {
return Promise.resolve('{"retries": 3}');
}

function parseConfig(text: string): number {
return JSON.parse(text).retries;
}

function withChaining(): Promise<string> {
return readConfig()
.then((text) => parseConfig(text))
.then((retries) => `retries: ${retries}`);
}

assertStrictEquals(await withChaining(), "retries: 3");
});
a gate, and one flat chain ... ok (56µs)

Two tools for the shapes that remain. Promise.withResolvers hands you a promise and its two settlement functions separately, for when the thing that settles the promise is somewhere else entirely: the result is a gate, where callers await it and something later opens it. Before withResolvers this meant declaring two variables and assigning them from inside an executor, which worked and read like a workaround. And withChaining is the reference shape for a chain: one flat sequence, returned. Three mistakes to recognise against it, all of them common. Losing the tail, where you call .then on a promise and return the original rather than the result, so the caller waits for the wrong thing. Nesting, where a .then goes inside a callback instead of returning the promise and continuing the chain, which adoption exists to prevent. And wrapping, where new Promise((resolve, reject) => { existing.then(resolve, reject); }) is a longer way of writing existing.

In practice

Related