JavaScript Deep Dive API Design: Writing Functions Developers Actually Want to Use

🟨 JavaScript Deep Dive — Issue #29

JavaScript API Design: Writing Functions Developers Actually Want to Use

Good APIs make the correct thing easy—and the wrong thing difficult.

You use APIs constantly in JavaScript.

And I don’t just mean REST APIs.

Every time you create a function like:

sendEmail(to, subject, body);

you’ve created an API.

Every class, module, utility function, library, component, and service exposes some kind of interface to the code around it.

Some APIs feel effortless:

cart.add(product);

Others make you stop and wonder:

update(true, false, 3, null, "active");

What do those arguments mean?

What can I safely change?

What does the function return?

What happens if something fails?

Great JavaScript isn’t only about writing code that works.

It’s about writing code that other developers—including future you—can understand and use correctly.

Let’s explore how.


🧠 1. Every Function Has an API

Consider:

function calculateTotal(price, quantity) {
  return price * quantity;
}

The API consists of more than the function name.

It includes:

Name
Inputs
Input types
Defaults
Return value
Errors
Side effects
Async behavior

Someone calling the function is entering into a contract:

const total = calculateTotal(20, 3);

They expect predictable behavior.

Good API design starts with one idea:

Make the contract obvious.


🟨 2. Names Should Explain Intent

Compare:

process(data);

with:

validateUserRegistration(data);

Or:

get(id);

with:

getUserById(id);

Shorter isn’t always clearer.

Names should communicate what the caller needs to know.

Good names often contain verbs:

createUser()
deleteUser()
findProduct()
calculateTotal()
formatCurrency()
validateEmail()

The goal isn’t maximum verbosity.

The goal is minimum uncertainty.


🟨 3. Boolean Arguments Are Often a Warning Sign

Imagine:

createReport(data, true, false);

What does this mean?

You have to inspect the function.

Maybe:

createReport(data, true, false);
// includeCharts = true
// sendEmail = false

Instead:

createReport(data, {
  includeCharts: true,
  sendEmail: false
});

Now the call explains itself.

Compare:

connect(true, false, 5000);

with:

connect({
  secure: true,
  retry: false,
  timeout: 5000
});

The second version is much harder to misunderstand.


🟨 4. Options Objects Scale Better

Positional parameters work well for small, obvious functions:

add(2, 3);

But imagine:

createUser(
  "Laurence",
  "laurence@example.com",
  true,
  "admin",
  false
);

Now you need to remember the parameter order.

An options object makes the interface clearer:

createUser({
  name: "Laurence",
  email: "laurence@example.com",
  active: true,
  role: "admin",
  sendWelcomeEmail: false
});

It also makes future changes easier.

You can add:

timezone: "America/Toronto"

without changing every existing call.


🟨 5. Use Defaults to Make Common Cases Easy

Suppose most users should be active.

Instead of requiring:

createUser({
  name: "Alex",
  active: true
});

every time, provide a default:

function createUser({
  name,
  active = true
}) {
  // ...
}

Now:

createUser({
  name: "Alex"
});

does the expected thing.

Good defaults reduce unnecessary decisions.

A useful API principle:

Optimize the interface for the common case without blocking advanced cases.


🟨 6. Avoid Surprise Mutation

Consider:

function applyDiscount(product) {
  product.price *= 0.9;
}

Then:

const product = {
  name: "Keyboard",
  price: 100
};

const discounted = applyDiscount(product);

The function secretly changes the original object.

That can surprise callers.

A more predictable API:

function applyDiscount(product) {
  return {
    ...product,
    price: product.price * 0.9
  };
}

Now:

const discounted = applyDiscount(product);

creates a new value.

Mutation isn’t automatically bad.

Unexpected mutation is.

If an API mutates something, that behavior should be obvious.


🟨 7. Return Values Should Be Predictable

Avoid functions that sometimes return unrelated types.

For example:

function findUser(id) {
  if (!id) return false;

  const user = users.find(
    user => user.id === id
  );

  if (!user) return "Not Found";

  return user;
}

Now the caller must handle:

false
string
object

That’s an awkward contract.

A cleaner design might be:

function findUser(id) {
  return users.find(
    user => user.id === id
  ) ?? null;
}

Now the result is:

User object
or
null

Much easier to reason about.


🟨 8. Decide What Failure Means

Suppose:

getUser(123);

cannot find the user.

Should it:

return null?
return undefined?
throw an error?
return a result object?

There isn’t one universal answer.

The important thing is consistency.

If “not found” is expected:

const user = findUser(id);

if (!user) {
  // handle missing user
}

may be appropriate.

If failure means the operation cannot continue:

const user = requireUser(id);

might throw.

The function name can even help communicate the contract.


🟨 9. Don’t Hide Errors

This looks convenient:

async function loadUser() {
  try {
    return await fetchUser();
  } catch {
    return null;
  }
}

But now:

User doesn't exist

and:

Server crashed

and:

Network disconnected

can all become:

null

Important information disappeared.

Instead, preserve meaningful failures.

async function loadUser() {
  const response = await fetch("/api/user");

  if (!response.ok) {
    throw new Error(
      `Failed to load user: ${response.status}`
    );
  }

  return response.json();
}

The caller can decide how to respond.


🟨 10. Async APIs Should Look Async

Consider:

function getUser() {
  return fetch("/api/user")
    .then(response => response.json());
}

That’s valid.

But callers need to understand that the result is asynchronous.

Naming and documentation should make this clear.

Then usage becomes:

const user = await getUser();

A dangerous design is one where a function sometimes returns immediately and sometimes returns a Promise.

Avoid contracts like:

sometimes value
sometimes Promise<value>

Consistency matters more than cleverness.


🟨 11. Return Useful Information

Consider:

saveUser(user);

and the function returns:

true

What does true mean?

Saved?

Validated?

Sent to the server?

Committed to the database?

A richer result might be:

{
  id: 42,
  saved: true,
  updatedAt: "2026-09-14T12:00:00Z"
}

But don’t return unnecessary complexity either.

The right question is:

What will the caller reasonably need next?

Design around that.


🟨 12. Avoid Making Callers Repeat Work

Imagine:

const user = await createUser(data);

const userId = user.id;

const fullUser = await getUser(userId);

Why does createUser() return an incomplete object if the complete result is already available?

A better API might simply return:

const user = await createUser(data);

with everything the caller normally needs.

Good APIs reduce ceremony.


🟨 13. Keep Abstraction Levels Consistent

Imagine this API:

shoppingCart.addProduct(product);
shoppingCart.removeProduct(product);
shoppingCart.items.push(product);

The first two calls use an abstraction.

The third reaches directly into internal state.

That’s inconsistent.

A cleaner interface:

shoppingCart.add(product);
shoppingCart.remove(product);
shoppingCart.clear();
shoppingCart.getItems();

The implementation remains hidden.

That gives you freedom to change it later.


🟨 14. Encapsulation Protects Future Changes

Consider:

class Cart {
  constructor() {
    this.items = [];
  }
}

External code might do:

cart.items.push(product);

Now your internal representation has become part of the public API.

Changing from an array later could break callers.

Instead:

class Cart {
  #items = [];

  add(product) {
    this.#items.push(product);
  }

  getItems() {
    return [...this.#items];
  }
}

Now callers depend on behavior rather than implementation.

That gives your code room to evolve.


🟨 15. Don’t Return Internal Mutable State

Even with:

getItems() {
  return this.#items;
}

the caller can do:

cart.getItems().length = 0;

Your private state has effectively escaped.

Instead:

getItems() {
  return [...this.#items];
}

Now the caller receives a copy.

This protects the object’s internal invariants.


🟨 16. Design APIs Around Capabilities

Sometimes APIs expose too much.

Imagine:

database.executeRawSQL(...);

available everywhere in your application.

Every caller now has enormous power.

Instead, expose the capabilities each part actually needs:

users.findById(id);
users.create(data);
users.update(id, changes);

Smaller interfaces are:

easier to understand
easier to test
harder to misuse
easier to change

A powerful design rule:

Expose the smallest useful surface area.


🟨 17. Make Invalid States Hard to Express

Consider:

createAccount({
  status: "active",
  deleted: true
});

Can an account be active and deleted simultaneously?

If not, your API permits an invalid state.

Perhaps instead:

createAccount({
  status: "active"
});

and deletion happens through:

deleteAccount(id);

The interface itself prevents contradictory combinations.

The fewer invalid combinations your API permits, the fewer checks every caller needs.


🟨 18. Validate at the Boundary

If an API expects:

createProduct({
  name,
  price
});

validate when data enters:

function createProduct({
  name,
  price
}) {
  if (!name) {
    throw new Error(
      "Product name is required"
    );
  }

  if (
    typeof price !== "number" ||
    price < 0
  ) {
    throw new Error(
      "Price must be a non-negative number"
    );
  }

  return {
    name,
    price
  };
}

Don’t allow bad data to travel deep into the application before failing.

Validate at boundaries.


🟨 19. Design for Cancellation When Operations Can Become Obsolete

From Issue #28, we saw how asynchronous work can outlive its usefulness.

An API that performs long-running async work may benefit from accepting an AbortSignal.

async function loadProducts({
  category,
  signal
}) {
  const response = await fetch(
    `/api/products?category=${category}`,
    { signal }
  );

  return response.json();
}

Then callers can control lifecycle:

const controller =
  new AbortController();

loadProducts({
  category: "books",
  signal: controller.signal
});

controller.abort();

Notice the design principle.

The API performs the work.

The caller controls whether that work is still needed.


🟨 20. Don’t Overload One Function With Everything

This is a common evolution:

save(data);

becomes:

save(
  data,
  validate,
  notify,
  backup,
  compress,
  retry,
  publish
);

Eventually the function is doing too much.

Instead, separate responsibilities:

validate(data);

const saved =
  await save(data);

await publish(saved);

await notify(saved);

Or create a higher-level workflow:

await publishArticle(data);

that coordinates smaller focused APIs internally.

Functions become easier to understand when they have a clear job.


🟨 21. Fluent APIs Can Improve Readability—But Don’t Force Them

Some libraries use chaining:

query
  .where("active", true)
  .sortBy("name")
  .limit(10)
  .execute();

This can be expressive.

But chaining isn’t automatically better.

Avoid building elaborate fluent interfaces when a simple function works:

findUsers({
  active: true,
  sortBy: "name",
  limit: 10
});

API design isn’t about making code look impressive.

It’s about making intent obvious.


🟨 22. Consistency Is a Feature

Imagine a library with:

getUser()
fetchProducts()
loadOrders()
retrieveAccount()

All four mean roughly:

retrieve data

The inconsistent naming forces developers to memorize arbitrary vocabulary.

A consistent API might use:

getUser()
getProducts()
getOrders()
getAccount()

Likewise, avoid inconsistent argument ordering:

updateUser(id, data);
updateProduct(data, id);
updateOrder(id, data);

Consistency reduces cognitive load.


🟨 23. Backward Compatibility Matters

Suppose your API starts with:

createUser(name);

Later you need email.

Changing it to:

createUser(name, email);

may be manageable.

Then comes:

createUser(
  name,
  email,
  role,
  active,
  timezone
);

Now evolution becomes painful.

An options object provides more flexibility:

createUser({
  name,
  email,
  role,
  active,
  timezone
});

New optional fields can often be added without breaking existing callers.

Good API design considers not just today.

It considers change.


🟨 24. Deprecate Before You Remove

Suppose:

getUserName()

needs to become:

getDisplayName()

Immediately deleting the old function may break consumers.

Instead:

function getUserName(user) {
  console.warn(
    "getUserName() is deprecated. Use getDisplayName()."
  );

  return getDisplayName(user);
}

Then remove the old interface in a clearly communicated future version.

APIs are relationships with callers.

Changing the contract has consequences.


🟨 25. Design the Call Site First

Here’s one of my favorite API design techniques.

Before implementing the function, write how you wish it could be used.

For example:

const report =
  await generateReport({
    data,
    format: "pdf",
    includeCharts: true
  });

Then design the implementation around that interface.

This shifts your thinking from:

“How do I implement this?”

to:

“What would make this easy to use correctly?”

That’s a powerful change in perspective.


🔥 Refactoring a Difficult API

Imagine we inherit this:

processUser(
  user,
  true,
  false,
  3,
  "admin",
  null
);

We have several problems.

What do the booleans mean?

What does 3 mean?

Why is null required?

What does the function return?

Let’s redesign it.

const result =
  await processUser({
    user,
    validate: true,
    sendNotification: false,
    retryCount: 3,
    role: "admin"
  });

Better.

But perhaps processUser itself is too vague.

Maybe what we’re actually doing is:

const user =
  await registerUser({
    name,
    email,
    role: "admin",
    sendWelcomeEmail: false
  });

Now the API communicates intent.

That is the goal.


🧩 API Design Challenge

Which interface would you rather maintain?

Challenge #1

send(
  message,
  true,
  false,
  5000
);

or:

send(message, {
  urgent: true,
  retry: false,
  timeout: 5000
});

Challenge #2

function getUser(id) {
  if (!id) return false;
  if (!users[id]) return "missing";

  return users[id];
}

How could you make the return contract more predictable?


Challenge #3

function sortUsers(users) {
  return users.sort(
    (a, b) =>
      a.name.localeCompare(b.name)
  );
}

Did the caller expect their original array to be modified?

Would this be safer?

function sortUsers(users) {
  return [...users].sort(
    (a, b) =>
      a.name.localeCompare(b.name)
  );
}

Challenge #4

async function loadData() {
  try {
    return await fetchData();
  } catch {
    return [];
  }
}

What information could this hide?


Challenge #5

const data =
  await loadDashboard();

If the user navigates away while it loads, should the API support cancellation?


🟦 Senior API Design Checklist

Before exposing a JavaScript function or module, ask:

✔ Does the name clearly communicate intent?

✔ Are the arguments obvious at the call site?

✔ Would an options object make this clearer?

✔ Are sensible defaults available?

✔ Is mutation obvious—or avoided?

✔ Is the return type predictable?

✔ Is failure behavior consistent?

✔ Are errors preserved rather than silently hidden?

✔ Is async behavior obvious?

✔ Can obsolete async work be cancelled?

✔ Am I exposing implementation details unnecessarily?

✔ Can callers put the system into invalid states?

✔ Am I returning internal mutable data?

✔ Is the API consistent with nearby APIs?

✔ Can this interface evolve without breaking callers?

And perhaps most importantly:

Could someone use this correctly without reading the implementation?

If the answer is yes, you’re probably designing a good API.


🏁 Final Thoughts

A lot of developers judge code by looking inside functions.

Experienced developers also look at the boundaries between them.

Because those boundaries determine how the system fits together.

A great implementation behind a confusing API still creates confusing software.

A simple, predictable interface makes everything around it easier to build.

The best JavaScript APIs often feel almost boring:

cart.add(product);

const user =
  await users.findById(id);

await report.generate(options);

controller.abort();

You don’t need to stop and decode them.

You can predict what they do.

And that is one of the strongest signs of good API design:

The developer can spend their time solving the problem instead of figuring out how to use your code.


🔜 Coming Next

Issue #30 — JavaScript Testing Beyond Unit Tests: What Should You Actually Test?

We’ll move beyond simply asking whether a function returns the expected value and explore:

  • unit vs integration vs end-to-end testing
  • what deserves a test
  • what usually doesn’t
  • testing behavior instead of implementation
  • async testing
  • race-condition testing
  • failure-path testing
  • mocking without mocking everything
  • testing API contracts
  • regression tests
  • and how to build a test suite developers actually trust

Because having hundreds of tests isn’t the goal.

Confidence is.