Designing good APIs
Design functions, classes, and modules that are easy to call correctly with options objects, clear naming, fluent APIs, and graceful deprecations.
- 01Write calls that explain themselvesSwap long lists of values and bare
true/falsearguments for named options, clear defaults, and separate functions. - 02Keep 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.
- 03Change 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.
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, andfind.
Your own APIs should feel just as familiar.
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 THROUGHA 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.
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.
true and 0.08 have no names. Right: an options object puts a name beside each choice.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
optionsis 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 aRangeError. These checks run before any formatting starts. - Lines 48 to 52 make the call. Each choice has a name.
couponis not a known option, so this call first printsUnknown formatPrice option: coupon, then$21.59.
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.
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
formatPricedoes. 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.
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));$21.59€19.99The 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.
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.
script
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" }));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
CONVENTIONSA 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
trueorfalse. Start its name withis,has, orcan: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
fmtPrcin public names. They are hard to search for and easy to misread.
// 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) isasync, 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."
| What you are naming | Write it like this | Avoid this |
|---|---|---|
| A function | Use 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 list | Use a plural: users, items, selectedIds. | Don't give an array a singular name. The call then reads wrong. |
| Slow or quick work | fetchUser always returns a Promise. getCachedUser answers right away. | Don't return a value one time and a Promise the next. |
| Results and errors | Return 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. |
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.
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 APIsMethod 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.
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 +.
.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.
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 INPUTSA 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.
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"));toast:shown:instantsetVisible("toast", true, false) works, but the call hides two choices. Is false about animation, about saving, or something else? The reader can't tell.
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:
showandhide, orrenderStaticandhydrate. - Put the value in a variable with a clear name first, then pass the variable.
Say which kinds of lists you accept
INPUTSBrowser 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 thatdocument.querySelectorAllreturns; 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...ofloop can walk through. It has aSymbol.iteratormethod. Arrays, strings, Sets, and Maps are iterable. - An array-like object has number keys (
0,1, and so on) and alength, 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.
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
nullorundefinedinto 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.
Set and the array-like object arrayLike both become the same array. A plain string throws a TypeError, unless the caller passes acceptStrings: true.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("|"));Ada, Lin
A Set is iterable, so Array.from copies its values in the order they were added.
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 THROUGHYour 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.
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 asfalse. - Lines 11 to 16 warn only while the flag is still
false, then set it totrue. So the warning prints once. - Lines 18 to 21 keep the old function,
showToast(message, isError). It warns, then turns the oldtrueorfalseinto the new options object.
Lines 23 to 26 print the warning once, then info, danger, danger, and 1000.
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.
Retiring an old name gently means: old calls keep working, one warning names the new function, and the new function stays clean.
script
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);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 ITReal 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.
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.
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."
| Kind of code | Shape to use | Why it helps |
|---|---|---|
| Functions that look like browser tools | Copy built-in shapes, such as addEventListener(type, listener, options) and fetch(url, init). | Callers already know how the built-in tools work. |
| Formatting helpers | Take the value first, then an options object with defaults. | Each optional choice has a name, and you can add more later. |
| Helpers for page elements | Accept 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 server | Use the same verbs everywhere: get, list, create, update, delete. | Callers can find the method they need quickly. |
| Packages other people install | Use @deprecated JSDoc tags, changelog notes, codemods, and major releases. | Retiring an API is a plan to help callers move, not just a warning. |
`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
getUserfunction 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.
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?
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."
| Shape | What the call looks like | When it fits | What to watch for |
|---|---|---|---|
| A list of values in order | formatPrice(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 object | formatPrice(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 chain | request("/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 EXERCISESRead the function, which gives its options defaults. What does the last line print?
function createToast(message, { tone = "info", duration = 3000 } = {}) {
return tone + ":" + message + ":" + duration;
}
console.log(createToast("Saved", { duration: 1000 }));The options object changes duration to 1000. tone uses its default, info. So the output is info:Saved:1000.
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.
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 }));show gives the action a verb. The optional boolean moves into a named object. The result is toast:shown:instant.
The function turns any iterable into an array. What names does it print?
function listNames(input) {
return Array.from(input).join(" & ");
}
console.log(listNames(new Set(["Ada", "Lin"])));Array.from(new Set(["Ada", "Lin"])) gives ["Ada", "Lin"]. Joined with & , the output is Ada & Lin.
Follow which check accepts fields, then type the output.
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("|"));Array.from reads key 0 and key 1 from the array-like object. So the output is button|input.
Follow the old name and the warning flag. Then type the three values it prints.
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);The old name passes each call to the new function with an options object, and warns only once. So the three outputs are info:Saved, danger:Failed, and 1.
Check your understanding
8 QUESTIONSQuestion 1 of 8What does API mean in this lesson?
Choose an answer to see the explanation.
Question 2 of 8What does this options object code print?
Read the code, then predictfunction 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.
Question 3 of 8Which change fixes the boolean trap best?
Choose an answer to see the explanation.
Question 4 of 8How many warnings does this code record?
Read the code, then predictlet 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.
Question 5 of 8What does this toArray function print?
Read the code, then predictfunction 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.
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.
Question 7 of 8What error name prints here?
Read the code, then predictfunction 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.
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
trueorfalsevalues 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.