The last lesson ended on a wound. To run a second asynchronous step after the first, you nested its callback inside the first, then a third inside the second, until your code curled into a rightward triangle of if (err) checks. That shape, callback hell, was never a discipline problem — it was a missing data type. A callback is a function you hope gets called. There is no value to hold, no result to return, and nowhere central for errors to land.
A Promise fixes exactly that. It turns 'arrange to be told' into a real object you can store in a variable, return from a function, and hand to other code. A Promise stands for a result that may not exist yet — a value on its way, or a reason it failed. Where a callback gave you no representation of the future at all, a Promise hands you that future as a thing you can act on.
flowchart LR P["pending"] --> F["fulfilled<br/>(a value)"] P --> R["rejected<br/>(a reason)"] style P fill:#e0900b,color:#fff style F fill:#1e7f3d,color:#fff style R fill:#b91c1c,color:#fff
Three states, and only one way through
Every Promise lives in exactly one of three states. It starts pending — the work has begun but has not finished. When the work succeeds it becomes fulfilled (often called resolved), carrying the value it produced. When it fails it becomes rejected, carrying a reason, usually an Error object. Once a Promise leaves pending for either of the other two, it is settled, and settled is permanent.
You create a Promise with new Promise(executor), where executor is a function you write that receives two levers handed to it by the engine: resolve and reject. Call resolve(value) to fulfil it, or reject(error) to reject it. The executor runs immediately, the instant you construct the Promise — not later, and not when someone gets around to consuming it.
const ok = true;
const loaded = new Promise((resolve, reject) => {
if (ok) {
resolve({ name: "Ada", role: "admin" });
} else {
reject(new Error("loading failed"));
}
});
console.log(typeof loaded); // "object"
console.log(loaded instanceof Promise); // true
loaded.then((user) => console.log("hi " + user.name));
Consuming a Promise: then and catch
A Promise you never listen to is a tree falling unheard. To act on its result you attach handlers with .then() and .catch(). Hand .then() a function and it runs if the Promise fulfils, receiving the value; hand .catch() a function and it runs if the Promise rejects, receiving the reason.
These handlers do not fire the instant the Promise settles. A fulfilled Promise schedules its .then callback as a microtask, which runs after the current synchronous code finishes — the rule you met in the previous lesson. That is why a .then callback always logs after the lines beneath it, even when the value was ready immediately. Attaching a handler can also happen long after the Promise settled; a fulfilled Promise simply holds its value and calls a newly attached .then on the next microtask.
flowchart LR PR["producer<br/>new Promise(executor)"] --> M["the Promise<br/>(its state + value)"] M --> T[".then(handler)<br/>on fulfilment"] M --> C[".catch(handler)<br/>on rejection"] style M fill:#3776ab,color:#fff style PR fill:#b45309,color:#fff style T fill:#1e7f3d,color:#fff style C fill:#b91c1c,color:#fff
function loadName(ok) {
return new Promise((resolve, reject) => {
if (ok) {
resolve("Ada");
} else {
reject(new Error("no name found"));
}
});
}
loadName(true).then((name) => console.log("hello " + name));
loadName(false).catch((err) => console.log("failed: " + err.message));
console.log("1: script start");
Promise.resolve().then(() => {
console.log("3: microtask ran");
});
console.log("2: script end");
Why this beats nested callbacks
Step back and notice what changed. A callback forced you to decide what to do with the result at the very moment you started the work, by embedding the next step inside the call. A Promise separates starting the work from reacting to it. You can start the work in one place, store the Promise, and attach a handler somewhere entirely different — even pass the Promise to a function that does not know how the work was begun.
Because a Promise is a value, you can also return it from a function, the way you would return a number or a string. Callers that need the result attach their own .then. That single change — the future as a returnable value — is what dissolves callback hell, and it sets up the next lesson on chaining, where one Promise's result feeds the next.
Settling is final
A Promise settles once and only once. The first call to resolve or reject wins; every later call is silently ignored, and a resolve followed by a reject still leaves the Promise fulfilled. This immutability is a feature, not a limitation: once a consumer sees a fulfilled value, it can trust that value will never change underneath it.
The practical mistake is calling the wrong lever for the outcome — resolving when something failed, or rejecting on success. The Promise faithfully reports whatever you told it, so a resolve(error) looks, to every consumer, exactly like a success. Match the lever to the truth: good results resolve, failures reject. You will repair a swapped lever in the exercises below.
flowchart TD A["resolve(42) first"] --> S["settled: fulfilled with 42"] B["resolve(99) later"] -.->|"ignored"| S C["reject(err) later"] -.->|"ignored"| S style S fill:#1e7f3d,color:#fff
What does this print? A Promise is an object, even when its value was ready immediately.
const p = Promise.resolve(7); console.log(typeof p); console.log(p instanceof Promise);
Promise.resolve(7) builds a fulfilled Promise holding the number 7.
typeof any Promise is "object", and every Promise is an instance of Promise.
Write resolveLater(value) that returns a Promise which resolves to value. The simplest way is to return Promise.resolve(value).
function resolveLater(value) {
// return a Promise that resolves to value
}
Promise.resolve(value) builds an already-fulfilled Promise.
The function must RETURN the Promise so callers can attach .then.
function resolveLater(value) {
return Promise.resolve(value);
}
This function should REJECT when amount is negative, but the author called resolve on the failure path. So a caller withdrawing a negative amount gets an Error object handed to their .then as if it were a success. Change the one lever on the failure path so that a negative amount rejects instead.
function withdraw(amount) {
return new Promise((resolve, reject) => {
if (amount < 0) {
resolve(new Error("negative amount"));
} else {
resolve(amount);
}
});
}
On the failure path, the lever must be reject, not resolve.
reject(new Error("negative amount")) sends the reason to every .catch.
function withdraw(amount) {
return new Promise((resolve, reject) => {
if (amount < 0) {
reject(new Error("negative amount"));
} else {
resolve(amount);
}
});
}
What does this print? The executor runs while the Promise is being constructed, before any .then is attached.
let runs = 0;
const p = new Promise((resolve) => {
runs = runs + 1;
resolve(runs);
});
console.log("executor ran " + runs + " times");
The executor function runs immediately, the moment new Promise is called.
So
runshas already become 1 before the console.log line runs.
Write makeRequest(ok) that returns a Promise. When ok is truthy, resolve with the object { status: 200, ok: true }. Otherwise reject with new Error("request failed"). This combines both levers in one executor.
function makeRequest(ok) {
// resolve with {status: 200, ok: true} when ok is truthy,
// otherwise reject with new Error("request failed")
}
Return new Promise((resolve, reject) => {...}) and branch on
ok.Resolve with the object literal; reject with a new Error carrying the message.
function makeRequest(ok) {
return new Promise((resolve, reject) => {
if (ok) {
resolve({ status: 200, ok: true });
} else {
reject(new Error("request failed"));
}
});
}
Recap
- A Promise is an object representing a single eventual result — a value or a reason — that you can store, return, and pass around.
- It has three states: pending, fulfilled, rejected; once it leaves pending it is settled and never changes again.
- Create one with
new Promise((resolve, reject) => {...}); the executor runs immediately. Callresolve(value)orreject(error)to settle it. - Consume it with
.then(handler)for the value and.catch(handler)for the reason; handlers run as microtasks. - Settling is final: the first lever wins, so always match the lever to the outcome, and never leave a rejection without a
.catch.
Next you will chain Promises, so the result of one becomes the input to the next — the pattern that finally flattens callback hell for good.