Module 10 · Capstone - Real-World Projects ⏱ 20 min

Project — API Client Simulation

By the end of this lesson you will be able to:
  • Model an API request as a Promise over an in-memory store and return JSON-like data
  • Route a found record to resolve and a missing record to reject with an Error
  • Handle failures with .catch and convert a rejection into a fallback value

Every modern application talks to something else — a login service, a weather API, a database behind a web server. Those calls are asynchronous: you ask for data, and instead of freezing the whole program while you wait, you receive a Promise that stands for the answer on its way. Building the code that makes those requests and reacts to the responses is one of the most valuable skills in JavaScript.

In this capstone you will model a realistic API client using only Promises and in-memory data — no network, no fetch, no DOM. A small object plays the server's database, and your client functions return Promises that resolve with a JSON-like object or reject with an Error. Every technique transfers straight to real fetch calls; you are simply swapping the network for a data structure you control.

flowchart LR
  C["your code<br/>getUser(1)"] -->|request| S["in-memory store"]
  S --> P["Promise (pending)"]
  P --> F["fulfilled<br/>user object"]
  P --> R["rejected<br/>Error"]
  style P fill:#e0900b,color:#fff
  style F fill:#1e7f3d,color:#fff
  style R fill:#b91c1c,color:#fff
A request returns a Promise that later settles: fulfilled with the record, or rejected with an Error.

Standing in for the server

A real API call travels over the network and comes back as text your code parses into an object. We skip the network entirely: our store is a plain object that maps ids to records, the way a database table maps keys to rows. To fetch a record you look it up by key.

The one piece we keep realistic is the asynchrony. Even though our lookup is instant, a real API client must hand back a Promise, because its callers are written to .then off the result. So our smallest building block is a function that wraps a value in a resolved Promise. That single wrapper is what lets the rest of the client pretend it is talking to a server.

A store maps ids to records. respond wraps any value in a resolved Promise. Press Run.
const users = {
  1: { id: 1, name: "Ada" },
  2: { id: 2, name: "Grace" }
};

function respond(data) {
  return Promise.resolve(data);
}

respond(users[1]).then((user) => {
  console.log("loaded " + user.name);
});

Looking up a record

The heart of the client is getUser(id). It returns a Promise that either resolves with the user object or rejects with an Error. The executor checks the store: a hit calls resolve, a miss calls reject. Nothing else.

This is the same resolve-or-reject choice you met in the Promises lesson, applied to a real decision the code has to make. Both branches live inside the executor — the Promise does not pick one for you. You decide what counts as success and what counts as failure, and you must be honest about it. A found record is a success; a missing record is a failure, and the client should say so by rejecting.

getUser resolves on a hit and rejects on a miss. Both outcomes have a handler attached.
const users = {
  1: { id: 1, name: "Ada" },
  2: { id: 2, name: "Grace" }
};

function getUser(id) {
  return new Promise((resolve, reject) => {
    if (users[id]) {
      resolve(users[id]);
    } else {
      reject(new Error("not found"));
    }
  });
}

getUser(1).then((u) => console.log("found " + u.name));
getUser(999).catch((e) => console.log("miss: " + e.message));
flowchart TD
  Q{"users[id] exists?"} -- Yes --> RES["resolve(user)"]
  Q -- No --> REJ["reject(new Error)"]
  RES --> T[".then receives user"]
  REJ --> C[".catch receives Error"]
  style RES fill:#1e7f3d,color:#fff
  style REJ fill:#b91c1c,color:#fff
A hit resolves the user to .then; a miss rejects an Error to .catch. Match the lever to the outcome.

Handling the failure

A rejected Promise is not a crash — it is a signal, and someone has to catch it. When getUser(999) rejects, the reason lands in the nearest .catch down the chain. There you decide what to do: log it, fall back to a default, or surface a message. Skip the .catch and you have an unhandled rejection: the error vanishes, and a missing record quietly becomes a bug somewhere far from its cause.

The same honesty applies when you build the Promise. The classic mistake is resolving on the failure path — handing an Error to resolve as if it were a successful value. Every consumer then sees an Error object inside their .then, which looks exactly like success. Match the lever to the outcome: good results resolve, failures reject.

Requesting in parallel

Real clients rarely fetch one thing at a time. A dashboard might ask for the current user, their posts, and their notifications together. If those requests are independent, do not await them one after another — that serialises work that could overlap. Start all the Promises, then hand the array to Promise.all, which resolves with an array of results in the same order, once every request has succeeded.

Promise.all is all-or-nothing: if any one request rejects, the whole batch rejects. That is usually what you want for a screen that is only meaningful when fully loaded. Knowing the shape — many Promises in, one Promise out, results in order — is what separates a slow client from a fast one.

flowchart LR
  A["getUser(1)"] --> ALL["Promise.all<br/>[...]"]
  B["getUser(2)"] --> ALL
  ALL --> R["[user1, user2]"]
  style ALL fill:#3776ab,color:#fff
  style R fill:#1e7f3d,color:#fff
Promise.all takes many Promises and resolves with one array of results, in input order.
Two independent requests, started together and joined with Promise.all.
const users = {
  1: { id: 1, name: "Ada" },
  2: { id: 2, name: "Grace" }
};

function getUser(id) {
  return new Promise((resolve, reject) => {
    if (users[id]) resolve(users[id]);
    else reject(new Error("not found"));
  });
}

Promise.all([getUser(1), getUser(2)]).then((all) => {
  console.log(all[0].name + " and " + all[1].name);
});

Designing the client interface

A client with only two methods can already start to drift, so hold its shape consistent. Decide once what each method takes and returns, and keep that contract everywhere: a getter resolves to the single record or rejects, a lister resolves to an array, and every failure arrives as an Error with a readable message. Callers can then lean on your client without opening its source, the way you use array methods without reading how they loop internally.

Consistency pays off most under failure. When every method rejects with the same Error shape, a single recovery point high in the chain can retry, cache, or report the problem without knowing which call broke. Drift away from that contract and each caller has to inspect its own result differently, which is precisely where asynchronous bugs settle in and stay hidden.

Exercise

Write respond(data) that returns a Promise which resolves to data. The simplest way is Promise.resolve(data).

function respond(data) {
  // return a Promise that resolves to data
}
Exercise

Write getUser(id) that looks up users[id]. If the record exists, resolve with it; otherwise reject with new Error("not found"). The store is provided for you.

const users = { 1: { id: 1, name: "Ada" }, 2: { id: 2, name: "Grace" } };

function getUser(id) {
  // resolve with users[id] if it exists, else reject(new Error("not found"))
}
Exercise

This fetchUser should REJECT when id is not greater than 0, but the author called resolve on the failure path — so a caller with an invalid id gets an Error handed to their .then as if it were success. Change the failure path so an invalid id rejects instead.

function fetchUser(id) {
  return new Promise((resolve) => {
    if (id > 0) {
      resolve({ id: id });
    } else {
      resolve(new Error("invalid id"));
    }
  });
}
Exercise

What does this print? Calling the client returns a Promise immediately — not the user object. (Only the synchronous lines are captured.)

function getUser(id) {
  return new Promise((resolve) => {
    resolve({ id: id, name: "Ada" });
  });
}

const result = getUser(1);
console.log(typeof result);
console.log(result instanceof Promise);
Exercise

Transfer task. Write safeGetUser(id) that returns the user if found, and resolves to null if not — it must never reject. Convert the rejection from getUser into the value null using .catch. The store and getUser are provided.

const users = { 1: { id: 1, name: "Ada" }, 2: { id: 2, name: "Grace" } };

function getUser(id) {
  return new Promise((resolve, reject) => {
    if (users[id]) {
      resolve(users[id]);
    } else {
      reject(new Error("not found"));
    }
  });
}

function safeGetUser(id) {
  // return the user, or null if not found (never reject)
}

Recap

  • An API client wraps lookups in Promises so callers can react asynchronously, even when the underlying data is local.
  • A store object stands in for a database; fetch by key.
  • resolve a found record; reject(new Error(...)) a missing one — and never route a failure through resolve.
  • Always attach a .catch, or a rejection becomes an unhandled rejection that vanishes.
  • Start independent requests together and join them with Promise.all — many Promises in, one Promise out, results in order.

You have now modelled requests, responses, and errors the way a real client does. The final lesson closes the course by polishing everything you have built — refactoring, testing, and documenting it like a professional.

Checkpoint quiz

Your lookup function finds no record for the given id. What should it do?

What does Promise.all([a, b]) resolve to, once both Promises succeed?

Go deeper — technical resources