Designing good APIs
Design functions, classes, and modules that are easy to call correctly with options objects, clear naming, fluent APIs, and graceful deprecations.
- 01Design call sites that explain themselvesReplace long positional lists and boolean flags with named options, clear defaults, and separate functions when the meaning matters.
- 02Keep public surfaces predictableUse consistent naming, argument order, return shapes, async behavior, and helpful errors across a module.
- 03Evolve APIs without surprising usersAccept documented inputs, warn about deprecated calls once, keep aliases temporarily, and plan semver-major removals.
APIs are promises to callers
In this lesson, an API is the public surface of your JavaScript functions, classes, and modules: the names, arguments, return values, errors, and migration rules other developers depend on. It is not only an HTTP endpoint.
A good JavaScript API is easy to use correctly and hard to misuse. It follows the principle of least surprise: call sites read clearly, behavior is consistent with the platform, and mistakes fail with helpful messages.
The platform gives you models to mirror. addEventListener(type, listener, options) takes required values first and an options object last. fetch(url, init) does the same. Array methods use predictable verbs like map, filter, and find. Your APIs should feel as unsurprising as those built-ins.
Imagine a control panel in a workshop. Every switch has a label, similar switches point in the same direction, and old controls get a warning sticker before they are removed. A good API does the same for code.
- In real life: Labeled switches
- In JavaScript: Named options such as
{ includeTax: true } - In real life: Same direction for every dial
- In JavaScript: Consistent argument order and return shapes
- In real life: Warning sticker before removal
- In JavaScript: Deprecation warning, alias, changelog, and semver major
- In real life: Guard rails around dangerous buttons
- In JavaScript: TypeError, RangeError, and validation near the boundary
Where the analogy stops: Real control panels cannot accept both arrays and iterables, and they do not evolve with semver. The analogy stops at labeling and predictable operation.
This lesson builds on functions basics, parameters, destructuring, and coding style. Later sections connect to iteration protocols, custom errors, creational patterns, types for JavaScript, and npm semver.
Options objects make call sites readable
STEP THROUGHLong positional lists work for small internal helpers, but public APIs tend to grow. Once a function has optional flags, numbers, and strings, callers need names at the call site. Put required arguments first, then gather optional choices into one object.
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 needs five positional values. Line 9 runs, but the reader has to remember that true means “include tax” and 0.08 means the tax rate. The second call repeats a tax rate that is ignored because includeTax is false.
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" }));Line 1 centralizes defaults. Line 8 lists the supported option names. Lines 10 through 20 normalize the incoming object: reject non-objects, warn about unknown keys, and merge defaults with caller choices. Lines 21 through 31 throw TypeError or RangeError near the boundary, before formatting work begins. The call on lines 48 through 52 names the optional choices and shows what a typo warning looks like.
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 positional call runs, but the reader must remember what true and 0.08 mean. Adding a new optional choice would change the parameter list.
Good APIs normalize inputs near the boundary: merge defaults, warn about unknown options, validate types, then 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" }));Do not hide required data inside an options object just to be fashionable. A good shape is often formatPrice(amount, options): the required thing comes first, and optional, extensible behavior lives in the object.
Names and conventions reduce surprise
CONVENTIONSNames teach the contract before documentation is opened. Use verbs for functions, nouns for values, is/has/can for booleans, plural names for collections, and consistent casing. Avoid abbreviations in public APIs because they are hard to search and easy to misunderstand.
// Names tell callers what kind of work happens.const userApi = { getCachedUser(id) { return cache.get(id); }, async fetchUser(id) { return fetch("/api/users/" + id).then((response) => response.json()); }, async loadUser(id) { return this.getCachedUser(id) ?? this.fetchUser(id); }, hasUser(id) { return cache.has(id); },};getCachedUser implies a synchronous lookup. fetchUser implies network work and a promise. loadUser can combine cache and network if it is documented to always return a promise or always be synchronous. Do not sometimes return a value and sometimes a promise; that “Zalgo” behavior makes callers guess whether to use await.
| Area | Prefer | Avoid |
|---|---|---|
| Function names | Use verbs: formatPrice, createToast, parsePage. | Avoid vague nouns like price() when a function performs work. |
| Booleans | Use is, has, can, or should: isOpen, hasUser, shouldRetry. | Avoid flag, status, or mode when the value is true/false. |
| Collections | Prefer plurals: users, items, selectedIds. | A singular name for an array makes call sites lie. |
| Async semantics | Let fetchUser always return a promise; let getCachedUser be synchronous. | Do not sometimes return a value and sometimes a promise. |
| Return shapes | Return the same shape for success; throw or return errors consistently. | A module that mixes exceptions, null, and { error } surprises callers. |
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); }}Use one error strategy per public surface. If a parser throws, make all invalid inputs throw helpful errors. Use TypeError when the type is wrong and RangeError when the type is right but the value is outside the accepted range. If a module returns { ok, error } instead, do that consistently everywhere.
Method chaining is a tool, not a default
FLUENT APIsA fluent API returns this or a new immutable object so callers can chain methods. It reads well when each call is a construction step. It hurts when each step hides side effects, swallows errors, or makes intermediate values hard to inspect.
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 stores request parameters. Line 4 accepts one named value, line 6 returns the same builder, and line 8 creates the final URL. This is close to the builder pattern from creational patterns. If you do not need repeated steps or validation, an options object is usually simpler.
Returning this mutates one builder. Returning a new object at each step can make chains easier to reason about in state-heavy code, but it costs more allocations. Pick the behavior deliberately and document it.
Avoid boolean traps
CHANGE INPUTSA boolean trap happens when a call like render(el, true) or setVisible(true, false) makes the reader memorize what each literal means. The boolean value is not the problem; the unnamed literal at the call site is.
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 decisions. Is false animation, persistence, focus, or something else?
Replace boolean traps with named options, separate functions, or named variables. A call like show("toast", { animate: false }) explains itself. If two modes are genuinely different behaviors, use two verbs: show and hide, or renderStatic and hydrate.
Document arrays, array-likes, and iterables
INPUTSBrowser APIs hand you many collection shapes: arrays, NodeList, arguments, strings, Sets, Maps, and custom iterables. Decide what you accept, then normalize at the boundary with Array.from or spread syntax. “Be liberal in what you accept” helps only when the trade-off is documented.
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("|"));Line 3 rejects strings unless the caller opts in, even though strings are iterable, because many APIs want a collection of items, not individual characters. Line 6 accepts real iterables such as Sets. Line 7 accepts array-like objects with numeric indexes and length. If your API accepts only arrays, say so and throw early for anything else.
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 reads its values in insertion order.
Deprecate APIs gracefully
STEP THROUGHAPI design does not end when version one ships. When a name or signature is wrong, keep the old surface as an alias for a while, warn once with a migration path, update docs, publish a changelog, offer a codemod when possible, and remove the old API in a semver-major release.
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 through 8 are the new API. Lines 10 through 15 store and update the one-time warning flag. Lines 18 through 20 keep the old boolean signature alive by translating it into the new options object. In documentation and TypeScript-aware editors, mark the old alias with @deprecated JSDoc and link to the replacement.
Graceful deprecation keeps old calls working, warns once with a clear replacement, and lets the new API stay 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 examples use console.warn. Node packages can use process.emitWarning with a DeprecationWarning type and stable code. Pair either warning with changelog notes, a version timeline, and a semver-major removal plan from npm package management.
Production API choices
SORT ITReal projects combine all of these choices. A module might expose a small factory, a class, a utility function, and a deprecated alias. The goal is not one perfect shape; the goal is a consistent public surface that feels like one team designed it.
| Use case | Likely API shape | Why it helps |
|---|---|---|
| Browser-style APIs | Mirror platform shapes such as addEventListener(type, listener, options) and fetch(url, init). | Developers transfer expectations from built-ins. |
| Formatting utilities | Use a required value plus an options object with defaults. | The call site names optional behavior and can grow later. |
| DOM helpers | Accept Iterable or array-like inputs only if documented. | NodeList, arguments, strings, Sets, and arrays do not all mean the same thing. |
| Client libraries | Choose consistent method names: get, list, create, update, delete. | Predictable verbs make modules easier to scan. |
| Packages | Use @deprecated JSDoc, changelog notes, codemods, and semver majors. | Deprecation is a migration plan, not just a warning. |
`fetch(url, { method: "POST", headers })` mirrors platform conventions.`render(root, true, false)` asks callers to remember two booleans.- An old alias logs one deprecation warning and delegates to the new function.
- A DOM helper documents
Iterable | ArrayLikeand normalizes withArray.from. - A
getUserfunction returns a cached object or a promise depending on cache state. - A package ships a codemod that rewrites
showToast(message, true)to named options. `setLimit(0)` throws `RangeError: limit must be at least 1`.`fmtPrc(19.99, "USD")` abbreviates a public function name.
Sort each card by what it represents. Look for named choices, boolean traps, consistent timing, and migration aids.
Common misconceptions
- “An options object is always better.” Required data should stay obvious. Use options for optional, named, extensible behavior.
- “Accept every input shape.” Liberal input handling can hide bugs. Document what you accept and normalize deliberately.
- “A fluent API is always cleaner.” Chaining is helpful for construction, but it can hide intermediate values and make debugging harder.
- “Deprecation means delete it.” Deprecation means “this will go away later.” Keep compatibility while users migrate.
- “Throwing is less friendly than returning errors.” Either can be friendly if it is consistent and documented. Mixing strategies is the real problem.
| Shape | Example | Best fit | Watch out |
|---|---|---|---|
| Long positional parameters | formatPrice(19.99, "USD", "en-US", true, 0.08) | Small internal helpers where every argument is required and obvious. | Hard to extend; booleans and numbers lose meaning at the call site. |
| Options object | formatPrice(19.99, { includeTax: true, taxRate: 0.08 }) | Public functions with optional choices, defaults, and future growth. | Validate unknown options so typos do not silently do nothing. |
| Builder or fluent API | request("/search").query("q", value).build() | Step-by-step construction, repeated calls, or validation in a final build(). | Chaining can hide intermediate values and make debugging harder. |
Practice exercises
5 EXERCISESPredict the output from a function that uses destructured defaults.
function createToast(message, { tone = "info", duration = 3000 } = {}) {
return tone + ":" + message + ":" + duration;
}
console.log(createToast("Saved", { duration: 1000 }));The options object overrides duration, while tone uses its default, so the output is info:Saved:1000.
Use the named wrapper and type the output that proves the old true, false call is no longer needed.
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 }));The wrapper gives the action a verb and moves the optional boolean into a named object, producing toast:shown:instant.
Normalize an iterable and predict the printed names.
function listNames(input) {
return Array.from(input).join(" & ");
}
console.log(listNames(new Set(["Ada", "Lin"])));Array.from(new Set(["Ada", "Lin"])) becomes ["Ada", "Lin"], so the output is Ada & Lin.
Trace the array-like branch and 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 index 0 and index 1 from the array-like object, so the output is button|input.
Follow the alias and warning flag, then type the three printed values.
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 alias preserves old behavior through the new options object and records one warning, 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 example 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 redesign avoids the boolean trap best?
Choose an answer to see the explanation.
Question 4 of 8How many warnings should this deprecation shim 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 array-like normalizer 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 a function that sometimes returns a value and sometimes a promise a bad public API?
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
- Design the public surface of functions, classes, and modules, not just HTTP endpoints.
- Use options objects for optional, extensible choices; keep required values visible.
- Mirror platform conventions and keep naming, argument order, return shapes, async behavior, and errors consistent.
- Use fluent APIs when chained steps help; avoid boolean traps and hard-to-debug chains.
- Document accepted collection shapes and deprecate old APIs with warnings, aliases, codemods, changelogs, and semver majors.
Remember the one-liner.
A good API makes the correct call obvious and the surprising call difficult.
This finishes the design patterns and architecture module. Up next, the curriculum moves into Build it from scratch, where you will rebuild platform behavior such as array methods to understand the contracts behind familiar APIs.