Photo by rawpixel on Unsplash

An API is an agreement between parts of a program, between providers and consumers of some functionality. The archetypical API interaction is between a data service, such as Google Maps, and a website that sends it some data (e.g. two addresses) and receives data in return (e.g. a list of steps for getting from the first address to the second).

The providers and consumers of an API don’t have to be so separated, though. The interaction described above, where the consumer sent data to a provider and received data back matches the description of a function. Functions can live very close to each other, even in the same file.

The I in API stands for “interface,” meaning the edge of where two black boxes touch. It might be odd to think of your code as a black box, but the API provider has no insight into your code other than what you send it, just as you have no insight into what the service provider does except through its response. While it’s possible, and common, for programmers to write functions that rely on implicit information outside of themselves, it’s often useful to treat each function as a black box with its own API.

Rationale

Why treat functions and objects in your code like black boxes, ignorant of each other’s internals and context? Why care about defining their APIs? The answer is that doing so enforces a separation of concerns. Properly segmented programs can evolve safely and rapidly, knowing that as long as the boundary between parts (the API) is respected, the parts themselves can change and improve. This boundary can be covered by tests to guarantee that it stays consistent while the internals change.

Writing an API

When a function is a black box, its API is the primary way it communicates with its consumers. (The other ways are through the documentation and through the source code.) It is important that this interface, the function call, is as predictable and self-documenting as possible. There are several things you can do in writing this API that will help.

You can name things well. Is the function retrieving a value? Call it getValueName . Use set* for setting values. Subscribe or respond to events with onEvent . A constructor should be a capitalized noun (e.g. new Car() ) but most functions should be verbs because they act as the verb in a Subject-Verb-Object sentence (e.g. slideshow = combine(slides); ).

Avoid encoding the return type in the name, and instead describe the action the function will perform. getLocations beats getLocationArray . Return types can be encoded with a type system that the IDE will use.

In general, keep names long enough to describe the function and short enough to use easily. Avoid abbreviations and keep names pronounceable to facilitate discussing them. If you find yourself struggling to name the function, that’s a red flag that the function may be doing more than one thing and could benefit from being broken up.

You can minimize argument length. Zero or one argument is best, as there’s no confusion in argument order then. I like to limit my functions to three or less arguments, and use an object if I need more data passed in. Using an object gives your function pseudo-named-arguments:

// self-documenting function call

new Character({

magic: true,

strength: 3,

movement: 5,

weapons: ['sword', 'staff'],

abilities: ['curse', 'stab'],

}) // wtf??

new Character(

true,

3,

5,

['sword', 'staff'],

['curse', 'stab']

)

In general, your function should request the least information necessary for it to perform its task. Never request information that can be reasonably derived from other arguments.

You can avoid boolean arguments as they are less descriptive when read. For example increaseSize(false) is confusing. Is it increasing the size or not? Is it setting some optional variation of size? Instead of requiring a boolean parameter, either require an object with a boolean property ( increaseSize({ width: false }) ), or split the function into two ( increaseHeight and increaseSize ), neither of which require the argument.

You can split different behavior into separate functions rather than splitting the behavior of a single function. Similar to the above example, splitting behavior based on inputs is called “overloading” a function. Done well, overloaded functions can be a magical user experience. Done poorly and the function can become a quagmire of confusion. Either way, be aware that you are sacrificing predictability for a reduced API surface.

You can add optional parameters that default to the current behavior to the end of a function signature. Let’s face it, code changes. Sometimes a function wasn’t as well-written as it could have been. Sometimes, you recognize too late (after the function is being used) that it relies on an assumption that may need to change. You could write a new function, as advised previously, to handle the same task but with the changed assumption, or you could tack on an optional argument. If you do this, make it default to the previous assumption so that you don’t force current uses to change.

You can predictably order arguments. Optional arguments go at the end so they can be omitted. It’s frustrating to have to write doThing(null, null, 3) because the first two arguments were optional. Even among optional arguments, some are more optional than others, such as the endIndex in Array.prototype.slice , so order them in ascending order of optionality. Similarly, if you combine all of your optional or configuration arguments into an options object argument, it should come last — throttle(fn, 300, { leading: true }) .

Mutable or “important” arguments come first, such as copy(array, startIndex, endIndex) or merge(toObject, fromObject) . The relative “importance” (I’m leaving this vague on purpose) of arguments should continue in one direction, such as in pipe(firstFn, secondFn, thirdFn) and in compose(thirdFn, secondFn, firstFn) . I don’t have a good way of determining which order this should be in but it should be in an order, not like in doStuff(secondFn, firstFn, thirdFn) .

You can not write the function. You can opt to keep the logic inline instead of into a new function. Remember that abstracting logic into a function introduces indirection and does not reduce duplication. Before introducing a new function, be sure the benefits are worth that cost.

Also, expose fewer of your functions to areas of the program that don’t need it. Encapsulate your helpers close to where you need them, and prevent access from other places. If the function is discovered to be helpful elsewhere, it can be lifted up and exposed as part of the Official™ API of the program. Otherwise, hide it in a closure, or as an unexported method in a module.

A typical excuse for broadly exposing functions/logic is that it is necessary for testing. Testing should always be done blindfolded. What you should be testing is the exposed interface, not the internal mechanisms or state. An unexposed helper function will be covered in tests when the exposed consumer of the helper is completely covered.

You can return consistent values from your function. If one branch of the function returns a Promise, every branch should return a Promise. Does the “happy path” returns an array? Make the “sad paths” return empty arrays. Avoid making the consumers of your function check what it returned. Interacting with the output of a function should be as predictable as invoking it.

A Note on RO/ROs

The RO/RO pattern (request object / return object) is great and I recommend using it, but it introduces a new challenge: naming properties of the object(s). In the spirit of keeping the API consistent, the objects should be well-named (see earlier section on this) and should grow only. Deprecating or renaming a property in one of these objects is a breaking change that affects consumers of the function.

Conclusion

By keeping the boundaries between functions small, predictable, and well-defined, you can prevent change from propagating wildly through your program. Changes in code that do not change an API will bring their improvements for a minimal cost, as no consumer of the API will need to change. Treating function signatures as APIs enables tests to easily cover every possibility for inputs and outputs without manipulating or depending on the function’s internals or context. This leads to robust testing suites that inoculate programs against bugs.

Additional Reading

https://emptysqua.re/blog/api-evolution-the-right-way/