API-First: How to Write Useful Functions in JavaScript
19 Mar 2022
Functions are the bread and butter of a computer program. They help encapsulate business logic, allowing you to easily reuse and build on your existing code. If done wrong, however, functions can easily become confusing, repetitive, and frustrating to use. In this article I'll lay the foundation for you to consistently create intuitive and useful functions.
Each function has a pre-determined way that it can be called. A signature, footprint, or interface, if you will. In this article, I'll be referring to this as the function's API (not to be confused with the kind which returns data from a URL).
The API-first approach is a simple concept:
When you're about to write a new function, start off by writing the call for it as if it already exists.
This is a powerful trick designed to shift your focus away from the implementation details of the function and to its API. This perspective will help you write more useful, satisfying, and reusable functions.
A function's API consists of three main parts. When writing a function from scratch, you have full creative freedom over all three:
- The function's return value
- The name of the function
- The function's input arguments
The order of these parts may seem unintuitive. If you think about it from an API-first approach, though, it makes perfect sense. Here's what a typical function call looks like:
// 1. 2. 3.
const user = getUser("John Doe");
- Return value
- Function name
- Function arguments
The first (and only) thing the caller is interested in is the return value. In order to get that return value, it needs to know which function to call. Finally, the arguments are information which the function requires the caller to provide.
Return Value
Return values can drastically alter the way your functions are consumed and can help tie together a truly satisfying function API. JavaScript functions will typically return one of three types:
- A single value
- An array of values
- An object of values
A Single Value
This is the most basic function API:
let age = getUserAge("John Doe");
When you have simple a function with one primitive value to return, this should be your go-to. To better understand how this compares to the other two options, let's see what they have to offer.
An Array of Values
If you've ever used React with Hooks, you'll recognize the following (if you haven't, don't worry - you'll still be able to follow along fine):
let [count, setCount] = useState(0);
If you're unfamiliar with array destructuring, this may seem a little alien at first. Once you get the basic idea, though, this kind of syntax becomes intuitive (and very powerful).
What's happening here is that useState()
returns an array. The first element in the array is a variable (which we've named count
) and the second element is a setter function (which we've named setCount
) to update the value of that variable. The input to useState, 0
, specifies the initial value of the variable.
When should you use this in your own functions?
This is most useful you want to return multiple, unrelated variables whose names may change. This API is most useful for very generic functions (such as useState()
) where you can't make any assumptions about what to name the return values. This syntax passes the control of return variable names over to the caller. A common pattern for useState()
looks something like this:
let [count, setCount] = useState(0);
let [message, setMessage] = useState("");
let [isLoading, setIsLoading] = useState(true);
Here, the array destructuring syntax gives the caller full control over naming multiple return values—allowing them to easily make multiple calls to useState()
without having to worry about renaming its output.
Imagine that we're implementing useState()
and we return an object instead of an array:
function useState(...params) {
// ...
- return [state, setState];
+ return {state, setState};
}
Here's how the code consuming the function would change:
// Array destructuring
let [count, setCount] = useState(0);
let [message, setMessage] = useState("");
let [isLoading, setIsLoading] = useState(true);
// Object destructuring
let { state: count, setState: setCount } = useState(0);
let { state: message, setState: setMessage } = useState("");
let { state: isLoading, setState: setIsLoading } = useState(true);
Which one feels more natural, more satisfying, less clumsy? In this case it's definitely the array destructuring approach.
An Object of Values
I briefly introduced object destructuring above. I find that once you've learned about array destructuring, object destructuring makes intuitive sense. Now that we've seen an example of object destructuring done wrong, let's find out how to do it right.
Let's imagine we want to display some basic information about a user. We have the user's ID and we need the user's name, age, and relationship status. Using the API-first approach, we might write something like this:
const userId = 1234;
const { name, age, relationshipStatus } = getUser(userId);
This line of code tells us exactly what the getUser()
function API should look like. If this shape of user object already exists in our system, we can return it directly:
function getUser(id) {
// Get the user from somewhere
// user = {
// id: 1234,
// name: 'John Doe',
// age: 35,
// relationshipStatus: 'single'
// };
return user;
}
This works even if the returned object contains more properties than the ones we specified (we used object destructuring to define variables for name
, age
, and relationshipStatus
, ignoring id
).
Let's see what would happen if we use array destructuring here instead:
function getUser(id) {
// Get the user from somewhere
// user = {
// id: 1234,
// name: 'John Doe',
// age: 35,
// relationshipStatus: 'single'
// };
- return user;
+ return [user.name, user.age, user.relationshipStatus];
}
const userId = 1234;
const [name, age, relationshipStatus] = getUser(userId);
Looks harmless enough, right? The problem here is that the variable names have lost their meaning. We might has well have written:
const [a, b, c] = getUser(userId);
Not that you would ever want to. It's still relevant though, because we now have to rely on the order of the array elements to make sure everything goes in its place. If we're not careful, we might do something like this:
const [age, relationshipStatus, name] = getUser(userId);
console.log({ age, relationshipStatus, name });
// {
// age: 'John Doe',
// relationshipStatus: '35',
// name: 'single'
// }
Object destructuring prevents this kind of error from ever happening. The trade-off is that the function and the caller must agree on the shape of the object being returned to make sure that the age
, name
, and relationshipStatus
properties all exist on the returned user
object (in which case TypeScript is your friend).
Function Name
While it may look trivial, the name of a function is probably the most important part of its API. The name of a function is a promise to the piece of code that calls it. The name creates an expectation for what the function does. If the actual outcome from calling the function doesn't match the expectation, the promise is broken and the function is useless.
This naming-cheatsheet is a fantastic, compact resource for some solid naming rules. To reiterate some of its points:
- Use natural English language
- Names should be S-I-D: Short, Intuitive, and Descriptive
- Use the
action + high context + low context
pattern (e.g.getUserMessages
)
Again, remember that the name is a promise. Let the name guide you while implementing the function. If you find yourself breaking the promise (such as by creating side-effects other than what the name suggests), you'll want to either change the name or break your function apart into two or more functions.
Function Arguments
Function arguments are a vital component to how satisfying your functions feel to use. When you add an argument to your function, you obligate its caller to make the extra effort of providing that argument. If your functions frequently take three or more arguments, you should think very carefully about ways to reduce that number.
It's somewhat tricky to give general advice on how to reduce function arguments so I'll provide a few rules of thumb instead.
Group Related Arguments Together
Use objects to group together related arguments:
const user = { name: "John Doe", age: 35, relationshipStatus: "single" };
// Bad
function renderUserProfile(
name,
age,
relationshipStatus,
hasBorder,
widthPx,
heightPx
) {
// Do something
}
renderUserProfile(user.name, user.age, user.relationshipStatus, true, 50, 100);
// Good
function renderUserProfile(user, options) {
// Do something
}
renderUserProfile(user, { hasBorder: true, width: 50, height: 100 });
Do One Thing
While writing, maintaining, and re-writing your functions, avoid falling into the pattern of adding more functionality than your function has promised to deliver. Be aware of your function's responsibilities and when it starts to outgrow them, create new functions instead to cover your growing needs.
Use Sensible Defaults
Sometimes you can give your function caller the benefit of fewer arguments by setting defaults. This can be useful when your function needs a certain parameter but can get by with a sensible default.
I use the word sensible very deliberately. You should only set default values that are meaningful for your function's use case. Here's an example of a bad default:
function createProductListing(name, priceInCents = 0) {
// Do something
}
If the caller doesn't provide the price to this function, the product will be listed for free! That's usually not what you want. Here's an example of a more sensible default:
function createUser(name, age, relationshipStatus = "unknown") {
// Do something
}
Here, we alleviate the caller from providing the relationshipStatus
argument without making consequential assumptions for its fallback value.
To Summarize
In order to create functions that are satisfying to use, focus on the way they're used. The API-first approach helps you focus on this aspect by laying down a blueprint for what you want your function to look like. To reiterate:
When you're about to write a new function, start off by writing the call for it as if it already exists.
Use different return values and destructuring assignment to your advantage to create a satisfying function API.
A function name is a promise to the caller. Choose a name that is S-I-D: Short, Intuitive, and Descriptive and follows the action + high context + low context
pattern.
Keep function arguments to a minimum by grouping related arguments together, having your functions do one thing, and by using sensible defaults.