completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Designing good APIs

Design functions, classes, and modules that are easy to call correctly with options objects, clear naming, fluent APIs, and graceful deprecations.

By the end, you can
  • 01
    Write calls that explain themselvesSwap long lists of values and bare true/false arguments for named options, clear defaults, and separate functions.
  • 02
    Keep an API easy to guessUse the same kind of names, argument order, results, timing (right away or with a Promise), and errors everywhere in a module.
  • 03
    Change an API without breaking callersSay which inputs you accept, keep an old name working, warn once, and remove old names only in a major release.

APIs are promises to callers

An API is the part of your code that other people call. In this lesson, that means your JavaScript functions, classes, and modules. The person who writes the call is the caller. The API is everything the caller can see and depend on:

  • the names,
  • the arguments it takes,
  • the value it returns,
  • the errors it throws,
  • how it changes from one version to the next.

An API is not only a web address on a server. Any function that someone else calls is an API. The line of code where they call it is the call site.

Plain definition

A good API is easy to use the right way and hard to use the wrong way. It does what callers expect, with no surprises. Each call is easy to read. It works like JavaScript's own built-in tools. When a caller makes a mistake, it fails with a message that says what went wrong.

JavaScript's built-in tools are good models to copy:

  • addEventListener(type, listener, options) takes the values it needs first, then an object of extra choices last.
  • fetch(url, init) does the same: the address first, then an object of choices.
  • Array methods use short, clear verbs, like map, filter, and find.

Your own APIs should feel just as familiar.

Real-life analogyA TV remote with clear buttons

A TV remote is easy to use when each button has a clear label. Press the same button, and the same thing happens every time. If the makers change a button, they put a note beside the old one. The note tells you which button to use now.

In real life: A button with a label on it
In JavaScript: A named option, such as { includeTax: true }
In real life: The same button always does the same thing
In JavaScript: The same arguments always give the same kind of result
In real life: A note beside an old button: use the new one
In JavaScript: A warning that says which new function to use
In real life: A button that beeps if you press it wrong
In JavaScript: A check that throws a clear error

This lesson uses ideas from functions basics, parameters, destructuring, and coding style. These lessons are related too: iteration protocols, custom errors, creational patterns, types for JavaScript, and npm and version numbers.

Options objects make call sites readable

STEP THROUGH

A positional argument list means the caller passes values in a fixed order. The order decides what each value means. That is fine for a small helper with two or three values. But public functions tend to grow. Once a function takes several flags, numbers, and strings, a reader can no longer tell which value is which.

The fix has two parts. First, pass the values the function always needs, in order. Then put all the optional choices in one object, called an options object. Each choice in it gets a name.

Before: five values in a row, with no namesPop out in the code editor (opens in a new tab)JavaScript
function formatPrice(amount, currency, locale, includeTax, taxRate) {  const taxed = includeTax ? amount * (1 + taxRate) : amount;  return new Intl.NumberFormat(locale, {    style: "currency",    currency,  }).format(taxed);} console.log(formatPrice(19.99, "USD", "en-US", true, 0.08));console.log(formatPrice(19.99, "EUR", "en-US", false, 0.08));

Line 1 says the function takes five values, in this order. Lines 3 to 6 use Intl.NumberFormat, JavaScript's built-in tool that writes a number as money, such as $21.59.

Line 9 works and prints $21.59. But the reader has to remember that true means "add tax" and 0.08 is the tax rate, 8%. Line 10 prints €19.99. It passes 0.08 too, but that value does nothing there: includeTax is false, so no tax is added.

Left: formatPrice gets five values in a row: 19.99, USD, en-US, true, and 0.08. Nothing says what true and 0.08 mean. Right: 19.99 and an options object that holds includeTax: true and taxRate: 0.08. Each choice has a name.five values in a row19.99"USD""en-US"true0.08??an options object19.99includeTax: truetaxRate: 0.08options
Left: in a list of five values, true and 0.08 have no names. Right: an options object puts a name beside each choice.
After: the amount first, then named optionsPop out in the code editor (opens in a new tab)JavaScript
const defaultFormatPriceOptions = {  currency: "USD",  locale: "en-US",  includeTax: false,  taxRate: 0,}; const formatPriceOptionNames = ["currency", "locale", "includeTax", "taxRate"]; function normalizeFormatPriceOptions(options = {}, warn = console.warn) {  if (options === null || typeof options !== "object" || Array.isArray(options)) {    throw new TypeError("formatPrice options must be an object");  }  const unknown = Object.keys(options).filter(    (name) => !formatPriceOptionNames.includes(name)  );  if (unknown.length > 0) {    warn("Unknown formatPrice option: " + unknown.join(", "));  }  const settings = { ...defaultFormatPriceOptions, ...options };  if (!/^[A-Z]{3}$/.test(settings.currency)) {    throw new TypeError("currency must be a three-letter code");  }  if (typeof settings.locale !== "string") {    throw new TypeError("locale must be a string");  }  if (typeof settings.includeTax !== "boolean") {    throw new TypeError("includeTax must be a boolean");  }  if (typeof settings.taxRate !== "number" || settings.taxRate < 0) {    throw new RangeError("taxRate must be zero or greater");  }  return settings;} function formatPrice(amount, options = {}) {  if (typeof amount !== "number" || !Number.isFinite(amount)) {    throw new TypeError("amount must be a finite number");  }  const settings = normalizeFormatPriceOptions(options);  const taxed = settings.includeTax ? amount * (1 + settings.taxRate) : amount;  return new Intl.NumberFormat(settings.locale, {    style: "currency",    currency: settings.currency,  }).format(taxed);} console.log(formatPrice(19.99, {  includeTax: true,  taxRate: 0.08,  coupon: "FALL",}));console.log(formatPrice(19.99, { currency: "EUR" }));

The function does the same job, but the call is easier to read. Here is what each part does:

  • Lines 1 to 6 hold the defaults: the value each option has when the caller leaves it out.
  • Line 8 lists the option names the function knows.
  • Lines 10 to 20 tidy up the caller's object before it is used. This step is called normalizing. Line 11 throws an error if options is not an object. Lines 14 to 19 warn about any name the function doesn't know.
  • Line 20 copies the defaults, then the caller's choices on top. The three dots, ..., copy every property of an object. When both objects have the same name, the later one wins.
  • Lines 21 to 32 check each setting. A value of the wrong kind throws a TypeError. A tax rate below zero throws a RangeError. These checks run before any formatting starts.
  • Lines 48 to 52 make the call. Each choice has a name. coupon is not a known option, so this call first prints Unknown formatPrice option: coupon, then $21.59.
Defaults fill in the options the caller leaves outPop out in the code editor (opens in a new tab)JavaScript
function createToast(message, { tone = "info", duration = 3000 } = {}) {  return { message, tone, duration };} const saved = createToast("Saved");const quick = createToast("Saved", { duration: 1000 });console.log(saved.tone + ":" + saved.duration);console.log(quick.tone + ":" + quick.duration);

A toast is a small message that pops up for a few seconds, like "Saved". Line 1 uses destructuring: it pulls tone and duration out of the options object, and gives each one a default. The = {} at the end is a default for the whole object. It is used when the caller passes no object at all.

Line 5 passes no options, so both defaults are used. Line 6 changes only duration. Lines 7 and 8 print info:3000 and info:1000. A default is used when the property is missing or is undefined. It is not used for null.

Passing null is not the same as passing nothingPop out in the code editor (opens in a new tab)JavaScript
function createToast(message, { tone = "info" } = {}) {  return { message, tone };} createToast("Saved", null);

The = {} on line 1 only steps in when the argument is left out. Line 5 passes null on purpose. JavaScript then tries to pull tone out of null, and throws a TypeError.

So decide what null means for your API, and write it down. Either reject it with a clear message, or treat it the same as "no options". Don't let callers find out from a confusing error.

Option names the function doesn't know need a clear rule too. There are three choices:

  • Warn, like formatPrice does. This catches typos, and the rest of the call still works.
  • Throw an error. A strict API does this.
  • Accept them. Do this only if your API passes extra options on to another tool on purpose, and say so in your documentation.
Which call is easier to read?
Five values in a rowPop out in the code editor (opens in a new tab)JavaScript
function formatPrice(amount, currency, locale, includeTax, taxRate) {  const taxed = includeTax ? amount * (1 + taxRate) : amount;  return new Intl.NumberFormat(locale, {    style: "currency",    currency,  }).format(taxed);} console.log(formatPrice(19.99, "USD", "en-US", true, 0.08));console.log(formatPrice(19.99, "EUR", "en-US", false, 0.08));
What it printsfive values
console 1$21.59
console 2€19.99
Try it yourself

The call with five values works, but the reader must remember what true and 0.08 mean. Adding one more choice would mean adding a sixth value to the list.

Both versions print real prices. The difference is how much the call tells the next person who reads it.
Step through: tidy up the options, then format the price
Step 0 of 12Ready
Your turn: follow the blue line

A good API tidies up its options first: it fills in defaults, warns about unknown names, and checks each value. Only then does it do the real work.

Running in
  1. script
Next: line 1
  currency: "USD",  locale: "en-US",  includeTax: false,  taxRate: 0,}; const formatPriceOptionNames = ["currency", "locale", "includeTax", "taxRate"]; function normalizeFormatPriceOptions(options = {}, warn = console.warn) {  if (options === null || typeof options !== "object" || Array.isArray(options)) {    throw new TypeError("formatPrice options must be an object");  }  const unknown = Object.keys(options).filter(    (name) => !formatPriceOptionNames.includes(name)  );  if (unknown.length > 0) {    warn("Unknown formatPrice option: " + unknown.join(", "));  }  const settings = { ...defaultFormatPriceOptions, ...options };  if (!/^[A-Z]{3}$/.test(settings.currency)) {    throw new TypeError("currency must be a three-letter code");  }  if (typeof settings.locale !== "string") {    throw new TypeError("locale must be a string");  }  if (typeof settings.includeTax !== "boolean") {    throw new TypeError("includeTax must be a boolean");  }  if (typeof settings.taxRate !== "number" || settings.taxRate < 0) {    throw new RangeError("taxRate must be zero or greater");  }  return settings;} function formatPrice(amount, options = {}) {  if (typeof amount !== "number" || !Number.isFinite(amount)) {    throw new TypeError("amount must be a finite number");  }  const settings = normalizeFormatPriceOptions(options);  const taxed = settings.includeTax ? amount * (1 + settings.taxRate) : amount;  return new Intl.NumberFormat(settings.locale, {    style: "currency",    currency: settings.currency,  }).format(taxed);} console.log(formatPrice(19.99, {  includeTax: true,  taxRate: 0.08,  coupon: "FALL",}));console.log(formatPrice(19.99, { currency: "EUR" }));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Required versus optional

Don't put a value the function always needs inside the options object. A good shape is often formatPrice(amount, options). The value the function always needs comes first. The optional choices, which may grow later, go in the object.

Clear names make an API easy to guess

CONVENTIONS

A good name tells the caller what a function does before they read any documentation. Some simple rules:

  • Functions do work, so name them with verbs: formatPrice, createToast.
  • Values are things, so name them with nouns: price, user.
  • A boolean is a value that is only true or false. Start its name with is, has, or can: isOpen, hasUser.
  • Name lists in the plural: users, items.
  • Write names the same way everywhere, for example always in camelCase.
  • Don't use short forms like fmtPrc in public names. They are hard to search for and easy to misread.
Names say what kind of work happensPop out in the code editor (opens in a new tab)JavaScript
// Names tell callers what kind of work happens.const cache = new Map([["7", { name: "Asha" }]]);function getCachedUser(id) {  return cache.get(id);}function fetchUser() {  return Promise.resolve({ name: "New user" });}async function loadUser(id) {  return getCachedUser(id) ?? fetchUser();} const saved = loadUser("7");const missing = loadUser("8");console.log(saved instanceof Promise && missing instanceof Promise);Promise.all([saved, missing]).then((users) => {  console.log(users[0].name);  console.log(users[1].name);});

Each name hints at how the function works:

  • getCachedUser (line 3) reads a saved user from memory and answers right away. A cache is a store of things you already fetched, kept so you don't have to fetch them again.
  • fetchUser (line 6) suggests slow work over the network. Here it is a small stand-in that returns a Promise. A Promise is an object that hands you a value later.
  • loadUser (line 9) is async, so it always returns a Promise. That is true when the user is in the cache, and when it isn't.

Line 15 prints true: both calls gave back a Promise. Lines 17 and 18 then print Asha and New user. Later you can swap the stand-in for a real network call. Callers won't notice, because loadUser still always returns a Promise.

The table below sums up the naming rules. Read each row as: "for this kind of name, write it like this, and avoid that."

Naming rules that help callers guess what a function does
What you are namingWrite it like thisAvoid this
A functionUse a verb: formatPrice, createToast, parsePage.Don't use a plain noun like price() for a function that does work.
A boolean (true or false)Start with is, has, can, or should: isOpen, hasUser, shouldRetry.Don't use flag, status, or mode for a value that is only true or false.
A listUse a plural: users, items, selectedIds.Don't give an array a singular name. The call then reads wrong.
Slow or quick workfetchUser always returns a Promise. getCachedUser answers right away.Don't return a value one time and a Promise the next.
Results and errorsReturn the same kind of result every time. Report errors one way: always throw, or always return them.Don't mix thrown errors, null, and { error } in one module. Callers can't guess which they'll get.
Throw the right built-in error for each mistakePop out in the code editor (opens in a new tab)JavaScript
function parsePage(value) {  const page = Number(value);  if (!Number.isInteger(page)) {    throw new TypeError("page must be an integer");  }  if (page < 1) {    throw new RangeError("page must be at least 1");  }  return page;} for (const input of ["2", "0"]) {  try {    console.log(parsePage(input));  } catch (error) {    console.log(error.name + ": " + error.message);  }}

Line 1 defines parsePage, which turns text like "2" into a page number. It can fail in two ways:

  • Line 3: the value is not a whole number. That is the wrong kind of value, so line 4 throws a TypeError.
  • Line 6: the value is a whole number, but it is less than 1. The kind is right, but the size is wrong, so line 7 throws a RangeError.

The loop on line 12 tries "2" and then "0". It prints 2, then RangeError: page must be at least 1.

Pick one way to report errors for your whole API, and stick to it. If one function throws errors, the others should throw too. If your module returns an object like { ok, error } instead, do that everywhere.

A missing user is not a broken argumentPop out in the code editor (opens in a new tab)JavaScript
function findUserById(users, id) {  if (typeof id !== "string") throw new TypeError("id must be a string");  return users.find((user) => user.id === id) ?? null;} const users = [{ id: "7", name: "Asha" }];console.log(findUserById(users, "7").name);console.log(findUserById(users, "8"));try {  findUserById(users, 7);} catch (error) {  console.log(error.name);}

Line 2 throws a TypeError when id is not a string. That is a mistake by the caller. Line 3 returns null when the ID is a valid string but no user has it. That is a normal answer, not a mistake.

Line 7 prints Asha. Line 8 prints null. Line 10 passes the number 7, so the function throws. The catch block on lines 11 and 12 prints TypeError.

Method chaining helps sometimes, not always

FLUENT APIs

Method chaining means calling one method right after another: a.one().two().three(). It works when each method returns an object that has the next method. An API built for chaining is called a fluent API. Each method returns this, the same object, or a new object.

Chaining reads well when each call is one step in building something. It hurts when:

  • a step quietly changes something somewhere else,
  • a step hides an error,
  • you need to look at a value halfway through the chain.
A small request builder you can chainPop out in the code editor (opens in a new tab)JavaScript
function createRequest(path) {  const params = new URLSearchParams();  return {    query(name, value) {      params.set(name, String(value));      return this;    },    build() {      const query = params.toString();      return query ? path + "?" + query : path;    },  };} const url = createRequest("/search")  .query("q", "api design")  .query("page", 2)  .build();console.log(url);

Line 2 makes params, a URLSearchParams object. It is a built-in tool that stores name and value pairs for a URL. Line 4 defines query, which stores one pair. Line 6 returns this, the same builder, so you can call .query() on it again. Line 8 defines build, which turns the pairs into the final URL.

Lines 15 to 18 chain the calls. Line 19 prints /search?q=api+design&page=2. In a URL, the space in "api design" becomes +.

A cart called params holds q: api design and page: 2. Two .query calls on the same builder put them there. An arrow labelled .build() turns them into the string /search?q=api+design&page=2..query(name, value)q: "api design"page: "2"params.build()url"/search?q=api+design&page=2"
Each .query() call adds one pair to params and returns the same builder. Then .build() turns the pairs into one URL.

An object that builds something step by step, like this one, is called a builder. You can read more about builders in creational patterns. If you don't need repeated steps or a final check, an options object is usually simpler.

Change one builder, or make a new one each step?

Returning this changes the one builder at every step. Another choice is to return a new builder at each step and leave the old one as it was. This style is called immutable, which means "never changed". It can make code easier to follow when many parts of an app share the same data. But it makes a new object at every step, which uses a little more memory. Pick one on purpose, and say which one in your documentation.

Avoid boolean traps

CHANGE INPUTS

A boolean trap is a call like render(el, true) or setVisible(true, false). The reader has to remember what each true or false means. The boolean itself is fine. The problem is a bare true or false in the call, with no name beside it.

Fix a boolean trap
The trap, then the fixPop out in the code editor (opens in a new tab)JavaScript
function setVisible(element, visible, animate) {  const state = visible ? "shown" : "hidden";  const motion = animate ? "animated" : "instant";  return element + ":" + state + ":" + motion;} console.log(setVisible("toast", true, false)); function show(element, { animate = true } = {}) {  return setVisible(element, true, animate);}function hide(element, { animate = true } = {}) {  return setVisible(element, false, animate);} console.log(show("toast", { animate: false }));console.log(hide("menu"));
What it returnsbare values
setVisibletoast:shown:instant
Try it yourself

setVisible("toast", true, false) works, but the call hides two choices. Is false about animation, about saving, or something else? The reader can't tell.

A boolean is fine in a variable with a clear name. It is a trap when the reader must remember what a bare true or false means.

There are three ways out of a boolean trap:

  • Use a named option. show("toast", { animate: false }) explains itself.
  • Use two functions with clear verbs when the two choices do different jobs: show and hide, or renderStatic and hydrate.
  • Put the value in a variable with a clear name first, then pass the variable.

Say which kinds of lists you accept

INPUTS

Browser tools give you lists in many shapes. Here are the common ones:

  • a real array, like [1, 2];
  • a NodeList, the list of page elements that document.querySelectorAll returns;
  • arguments, the list of values passed to an old-style function;
  • strings, Sets, Maps, and your own objects.

Two words help you sort these out:

  • An iterable is anything a for...of loop can walk through. It has a Symbol.iterator method. Arrays, strings, Sets, and Maps are iterable.
  • An array-like object has number keys (0, 1, and so on) and a length, but none of the array methods.

Decide which shapes your function accepts. Then turn the input into a real array right at the start, with Array.from or the spread syntax [...input]. Some people say "accept as many kinds of input as you can". That only helps callers if you write down which kinds you accept.

Turn an iterable or array-like input into an arrayPop out in the code editor (opens in a new tab)JavaScript
function toArray(input, { label = "items", acceptStrings = false } = {}) {  if (input == null) return [];  if (typeof input === "string" && !acceptStrings) {    throw new TypeError(label + " must be an iterable of values, not a string");  }  if (typeof input[Symbol.iterator] === "function") return Array.from(input);  if (Number.isInteger(input.length) && input.length >= 0) return Array.from(input);  throw new TypeError(label + " must be iterable or array-like");} const arrayLike = { 0: "Ada", 1: "Lin", length: 2 };console.log(toArray(new Set(["Ada", "Lin"])).join(", "));console.log(toArray(arrayLike).join(", "));console.log(toArray("JS", { acceptStrings: true }).join("|"));

Here is what each check does:

  • Line 2 turns null or undefined into an empty array.
  • Line 3 rejects a string, unless the caller passes acceptStrings: true. Strings are iterable, but each item would be one character. Most list functions want whole items, like users or page elements.
  • Line 6 accepts any iterable, such as a Set.
  • Line 7 accepts any array-like object.
  • Line 8 throws for anything else.

Lines 12 to 14 print Ada, Lin twice, then J|S. If your function accepts only real arrays, say so, and throw right away for anything else.

toArray with three inputs. A Set of Ada and Lin becomes the array Ada, Lin. The array-like object arrayLike also becomes Ada, Lin. The string JS throws a TypeError by default.toArray(input)new Set(["Ada", "Lin"])["Ada", "Lin"]arrayLike["Ada", "Lin"]"JS"TypeError
A Set and the array-like object arrayLike both become the same array. A plain string throws a TypeError, unless the caller passes acceptStrings: true.
Which inputs does toArray accept?
Turn an iterable or array-like input into an arrayPop out in the code editor (opens in a new tab)JavaScript
function toArray(input, { label = "items", acceptStrings = false } = {}) {  if (input == null) return [];  if (typeof input === "string" && !acceptStrings) {    throw new TypeError(label + " must be an iterable of values, not a string");  }  if (typeof input[Symbol.iterator] === "function") return Array.from(input);  if (Number.isInteger(input.length) && input.length >= 0) return Array.from(input);  throw new TypeError(label + " must be iterable or array-like");} const arrayLike = { 0: "Ada", 1: "Lin", length: 2 };console.log(toArray(new Set(["Ada", "Lin"])).join(", "));console.log(toArray(arrayLike).join(", "));console.log(toArray("JS", { acceptStrings: true }).join("|"));
What toArray gives backSet

Ada, Lin

Try it yourself

A Set is iterable, so Array.from copies its values in the order they were added.

Accepting many kinds of input only helps callers when you write down which kinds you accept, and turn each one into an array on purpose.
Sort a copy, so the caller's array stays the samePop out in the code editor (opens in a new tab)JavaScript
function sortPrices(prices) {  return [...prices].sort((left, right) => left - right);} const prices = [8, 3, 5];const sorted = sortPrices(prices);console.log(sorted.join(", "));console.log(prices.join(", "));

The array method sort changes the array you call it on. So line 2 first copies the prices with [...prices], then sorts the copy. Line 7 prints the sorted copy, 3, 5, 8. Line 8 prints the caller's array, still 8, 3, 5. This matters when another part of the app still uses that array.

Retire an old API gently

STEP THROUGH

Your work on an API doesn't end when the first version ships. Sometimes a name or a list of arguments turns out to be wrong. Then you can deprecate it: mark it as old, and tell callers to stop using it. Callers need time to change their code, so do it gently:

  • Keep the old name working as an alias: a second name that calls the new function.
  • Warn once, and say what to use instead.
  • Update the documentation.
  • Write it in the changelog, the list of changes in each version.
  • If many callers must change, offer a codemod: a script that rewrites their code for them.

If you must remove the old name, do it in a major release. That is a new version whose first number goes up, like 2.0.0 after 1.4.0. The new first number tells callers that some old code may stop working. Some deprecated APIs are never removed at all.

Keep the old name working and warn oncePop out in the code editor (opens in a new tab)JavaScript
function createToast(message, options = {}) {  const {    tone = "info",    duration = 3000,    dismissible = true,  } = options;  return { message, tone, duration, dismissible };} let didWarnAboutShowToast = false;function warnShowToastDeprecated() {  if (!didWarnAboutShowToast) {    console.warn("showToast(message, isError) is deprecated; use createToast(message, { tone }).");    didWarnAboutShowToast = true;  }} function showToast(message, isError = false) {  warnShowToastDeprecated();  return createToast(message, { tone: isError ? "danger" : "info" });} console.log(showToast("Saved", false).tone);console.log(showToast("Failed", true).tone);console.log(showToast("Again", true).tone);console.log(createToast("Queued", { tone: "success", duration: 1000 }).duration);
  • Lines 1 to 8 are the new API, createToast(message, options).
  • Line 10 makes a flag, didWarnAboutShowToast, that starts as false.
  • Lines 11 to 16 warn only while the flag is still false, then set it to true. So the warning prints once.
  • Lines 18 to 21 keep the old function, showToast(message, isError). It warns, then turns the old true or false into the new options object.

Lines 23 to 26 print the warning once, then info, danger, danger, and 1000.

The call showToast("Saved", false). An old name tag, showToast, warns once and passes the call on to the createToast function with { tone: "info" }.showToast("Saved", false)showToastold namewarns once{ tone: "info" }createToast()does the work
The old name showToast still works. It warns once, turns false into { tone: "info" }, and lets createToast do the work.

In your documentation, mark the old function with a @deprecated JSDoc tag and link to the new one. JSDoc is a comment above a function that starts with /**. Many editors read it and cross out the old name wherever someone uses it.

Step through: the old name warns once and passes the call on
Step 0 of 12Ready
Your turn: follow the blue line

Retiring an old name gently means: old calls keep working, one warning names the new function, and the new function stays clean.

Running in
  1. script
Next: line 1
  const {    tone = "info",    duration = 3000,    dismissible = true,  } = options;  return { message, tone, duration, dismissible };} let didWarnAboutShowToast = false;function warnShowToastDeprecated() {  if (!didWarnAboutShowToast) {    console.warn("showToast(message, isError) is deprecated; use createToast(message, { tone }).");    didWarnAboutShowToast = true;  }} function showToast(message, isError = false) {  warnShowToastDeprecated();  return createToast(message, { tone: isError ? "danger" : "info" });} console.log(showToast("Saved", false).tone);console.log(showToast("Failed", true).tone);console.log(showToast("Again", true).tone);console.log(createToast("Queued", { tone: "success", duration: 1000 }).duration);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
In Node.js packages, use process.emitWarningJavaScript
process.emitWarning("showToast() is deprecated; use createToast().", {  type: "DeprecationWarning",  code: "DEP_SHOW_TOAST",});

Browser code warns with console.warn. Node.js packages can use process.emitWarning instead. Give it the type DeprecationWarning and a short code that never changes, like DEP_SHOW_TOAST, so people can search for it.

Either way, add a note to the changelog and say when the old name will go. If you plan to remove it, do that in a major release, as described in npm package management.

API choices in real projects

SORT IT

Real projects use all of these choices together. One module might offer a function that creates objects, a class, a helper function, and an old alias. There is no single perfect shape. The goal is an API that feels like one team designed all of it.

Add an option without breaking the old callPop out in the code editor (opens in a new tab)JavaScript
function listUsers(team, { limit = 20 } = {}) {  return team + ":" + limit;} console.log(listUsers("design"));console.log(listUsers("design", { limit: 5 }));

Line 5 is the old call, listUsers("design"). It still works and prints design:20. Line 6 uses the new named option and prints design:5. When you add an optional setting, keep the old default the same, so old calls work as before.

Export only what callers needJavaScript
function calculateTotal(items) {  return items.reduce((total, item) => total + item.price, 0);} export function createCart() {  return { items: [] };} export function addItem(cart, item) {  return { items: [...cart.items, item] };} const cart = addItem(createCart(), { name: "Tea", price: 3 });console.log(calculateTotal(cart.items));

This example is an ECMAScript module: a file that shares code with the export keyword. To run it, save it as a .mjs file for Node.js, or load it as a module in a browser project. Only createCart and addItem are exported. calculateTotal stays inside the file, so callers can't depend on it, and you can change it freely. Line 14 prints 3, the total for a cart with one tea that costs 3.

The table below shows where these choices appear in real projects. Read each row as: "for this kind of code, use this shape, because of this."

Where API design choices show up in real projects
Kind of codeShape to useWhy it helps
Functions that look like browser toolsCopy built-in shapes, such as addEventListener(type, listener, options) and fetch(url, init).Callers already know how the built-in tools work.
Formatting helpersTake the value first, then an options object with defaults.Each optional choice has a name, and you can add more later.
Helpers for page elementsAccept iterables or array-like objects only if your documentation says so.NodeList, arguments, strings, Sets, and arrays don't all mean the same thing.
Code that talks to a serverUse the same verbs everywhere: get, list, create, update, delete.Callers can find the method they need quickly.
Packages other people installUse @deprecated JSDoc tags, changelog notes, codemods, and major releases.Retiring an API is a plan to help callers move, not just a warning.
Good API choice or trap?
  • `fetch(url, { method: "POST", headers })` copies a built-in shape.
  • `render(root, true, false)` makes callers remember what two booleans mean.
  • An old name prints one warning, then calls the new function.
  • A helper for page elements says it accepts iterables or array-like objects, and turns them into arrays with Array.from.
  • A getUser function returns a saved object one time and a Promise the next, depending on its cache.
  • A package ships a codemod that rewrites showToast(message, true) to use named options.
  • `setLimit(0)` throws `RangeError: limit must be at least 1`.
  • `fmtPrc(19.99, "USD")` uses a short form in a public name.
Try it yourself
0 of 8 correct

Put each card in a group. Is it a good choice, a trap to fix, or something that helps callers move to a new API?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common misconceptions

  • "An options object is always better." A value the function always needs should stay easy to see, as its own argument. Use options for choices that are optional, have names, and may grow.
  • "Accept every kind of input." Accepting everything can hide bugs. Write down what you accept, and turn it into one shape on purpose.
  • "A fluent API is always cleaner." Chaining helps when you build something step by step. But it can hide the values in between, which makes bugs harder to find.
  • "Deprecate means delete." Deprecating marks an API as old and tells callers to move on. It doesn't promise the API will be removed. Keep it working while callers change their code.
  • "Throwing errors is less friendly than returning them." Both can be friendly if you use one way everywhere and write it down. Mixing the two is the real problem.

The table below compares three ways to shape a function call. Read each row as: "this shape looks like this, fits here, and has this risk."

Three API shapes that are easy to mix up
ShapeWhat the call looks likeWhen it fitsWhat to watch for
A list of values in orderformatPrice(19.99, "USD", "en-US", true, 0.08)Small helpers inside your own code, where every value is needed and its meaning is clear.Hard to add to. A bare true or 0.08 says nothing about what it means.
Options objectformatPrice(19.99, { includeTax: true, taxRate: 0.08 })Functions other people call, with optional choices and defaults, that may get more choices later.Check for unknown option names, so a typo doesn't quietly do nothing.
Builder you can chainrequest("/search").query("q", value).build()Building something step by step, repeating a step, or checking everything in a final build().A chain can hide the values in between, which makes bugs harder to find.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upDefaults in an options object

Read the function, which gives its options defaults. What does the last line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
function createToast(message, { tone = "info", duration = 3000 } = {}) {
  return tone + ":" + message + ":" + duration;
}
console.log(createToast("Saved", { duration: 1000 }));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeFix a boolean trap

    The new function show calls the old one for you. Type what it prints. The output shows you no longer need the old call with true, false.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    function setVisible(element, visible, animate) {
      const state = visible ? "shown" : "hidden";
      const motion = animate ? "animated" : "instant";
      return element + ":" + state + ":" + motion;
    }
    function show(element, { animate = true } = {}) {
      return setVisible(element, true, animate);
    }
    console.log(show("toast", { animate: false }));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeAccept any iterable

      The function turns any iterable into an array. What names does it print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      function listNames(input) {
        return Array.from(input).join(" & ");
      }
      console.log(listNames(new Set(["Ada", "Lin"])));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeAccept an array-like input

        Follow which check accepts fields, then type the output.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        function toArray(input) {
          if (typeof input[Symbol.iterator] === "function") return Array.from(input);
          if (Number.isInteger(input.length) && input.length >= 0) return Array.from(input);
          throw new TypeError("items must be iterable or array-like");
        }
        const fields = { 0: "button", 1: "input", length: 2 };
        console.log(toArray(fields).join("|"));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeChallenge: add an alias for an old name

          Follow the old name and the warning flag. Then type the three values it prints.

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          const warnings = [];
          function createToast(message, { tone = "info" } = {}) {
            return tone + ":" + message;
          }
          let warned = false;
          function showToast(message, isError = false) {
            if (!warned) {
              warnings.push("deprecated");
              warned = true;
            }
            return createToast(message, { tone: isError ? "danger" : "info" });
          }
          console.log(showToast("Saved", false));
          console.log(showToast("Failed", true));
          console.log(warnings.length);

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            8 QUESTIONS
            Lesson quiz · 8 questionsScore: first tries count
            1. Question 1 of 8What does API mean in this lesson?

              Choose an answer to see the explanation.

            2. Question 2 of 8What does this options object code print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function normalize({ currency = "USD", includeTax = false } = {}) {
                return { currency, includeTax };
              }
              const settings = normalize({ includeTax: true });
              console.log(settings.currency + ":" + settings.includeTax);

              Choose an answer to see the explanation.

            3. Question 3 of 8Which change fixes the boolean trap best?

              Choose an answer to see the explanation.

            4. Question 4 of 8How many warnings does this code record?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              let warned = false;
              let warnings = 0;
              function oldName() {
                if (!warned) {
                  warnings += 1;
                  warned = true;
                }
                return "ok";
              }
              oldName();
              oldName();
              console.log(warnings);

              Choose an answer to see the explanation.

            5. Question 5 of 8What does this toArray function print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function toArray(input) {
                if (typeof input[Symbol.iterator] === "function") return Array.from(input);
                return Array.from(input);
              }
              console.log(toArray({ 0: "a", 1: "b", length: 2 }).join(""));

              Choose an answer to see the explanation.

            6. Question 6 of 8Why is it bad for a function to return a value one time and a Promise the next?

              Choose an answer to see the explanation.

            7. Question 7 of 8What error name prints here?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function setLimit(limit) {
                if (!Number.isInteger(limit)) throw new TypeError("limit must be an integer");
                if (limit < 1) throw new RangeError("limit must be at least 1");
                return limit;
              }
              try {
                console.log(setLimit(0));
              } catch (error) {
                console.log(error.name);
              }

              Choose an answer to see the explanation.

            8. Question 8 of 8When does method chaining hurt more than it helps?

              Choose an answer to see the explanation.

            Key takeaways

            • An API is everything other people call in your code: functions, classes, and modules, not only web addresses.
            • Put values the function always needs first. Put optional choices, which may grow, in an options object.
            • Copy the built-in tools. Keep names, argument order, results, timing (sync or async), and errors the same across your API.
            • Chain methods when the steps help. Don't leave bare true or false values in calls.
            • Write down which kinds of lists you accept. Remove an old API only in a major release.

            Remember the one-liner.
            A good API makes the right call easy to write and the wrong call hard to write.

            This is the last lesson in the design patterns and architecture module. Next comes Build it from scratch. There you will rebuild built-in tools, such as the array methods, to see the promises they make to callers.

            CompleteFrontend Clear concepts. Working examples.