Designing API Methods in JavaScript
July 21st, 2019
Designing API methods in JavaScript is a very useful skill to learn and allows you to look at programming in a different perspective. A perspective whereas instead of building a project for your users, you're building a project for developers to use. And if you haven't built a library or an SDK before, this article may help you gain an understanding of where and how to start beginning with method design.
JavaScript is a loosely typed language which we as developers can take advantage of to create robust, multi-use APIs.
This article will go over a couple of rules to keep in mind when designing methods for an API in JavaScript.
The first one we are going to talk about is named parameters. Back in the old days before ES6 was introduced, the only way to declare functions were function declarations using the function
syntax. To determine where to go with execution flows, you would take a function arity (the number of arguments the function expects), convert it to an array and apply the rest of the logic depending on how the arguments look like.
In this example, the animal
, options
, and callback
are the parameters to the function and the arity will be three. The function is designed to create a new account, and each account will have some default settings if it wasn't provided by the caller:
function createAccount(
username = '',
password = '',
nickname = '',
email = '',
gender = 'Male',
bio = '',
subscription = 'Basic',
callback,
) {
if (!username || !password || !email) {
throw new Error(
'You are missing one or all of the following fields: "username", "password", "email"',
)
}
return api
.createAccount({
username,
password,
nickname,
email,
gender,
bio,
subscription,
})
.then((result) => {
if (callback) callback(null, result)
})
.catch((error) => {
console.error(error)
if (callback) callback(error)
})
}
createAccount(
'lucas',
'applebee123x123',
'',
'applebee1233@gmail.com',
'',
'My bio',
'Basic',
function cb(err, data) {
if (err) {
console.error(err)
}
// do something with data
},
)
The problem with this is that the caller has to know the exact order of arguments to pass in as the parameters to the function in order to function properly even if one or more parameters weren't required. It can be difficult to memorize the requirements in order while it can be very easy to mess up the order if you aren't careful. In addition, it doesn't really make much sense to make a parameter required if it isn't required to make the function do its work properly.
It will also be difficult to maintain in the future because when you or your boss needs to get rid of username
and make it email
as the new username instead, you would have to change the logic around.
A better practice is to simply use an object:
function createAccount({
username = '',
password = '',
nickname = '',
email = '',
gender = 'Male',
bio = '',
subscription = 'Basic',
callback,
}) {
if (!username || !password || !email) {
throw new Error(
'You are missing one or all of the following fields: "username", "password", "email"',
)
}
return api
.createAccount({
username,
password,
nickname,
email,
gender,
bio,
subscription,
})
.then((result) => {
if (callback) callback(null, result)
})
.catch((error) => {
console.error(error)
if (callback) callback(error)
})
}
We benefit from readability as well as more control over maintainability as you only need to remove the username from the code:
function createAccount({
password = '',
nickname = '',
email = '',
gender = 'Male',
bio = '',
subscription = 'Basic',
callback,
}) {
if (!password || !email) {
throw new Error(
'You are missing one or all of the following fields: "email", "password"',
)
}
return api
.createAccount({
password,
nickname,
email,
gender,
bio,
subscription,
})
.then((result) => {
if (callback) callback(null, result)
})
.catch((error) => {
console.error(error)
if (callback) callback(error)
})
}
Making the call also becomes more terse and readable:
createAccount({
password: 'applebee123x123',
email: 'applebee1233@gmail.com',
bio: 'My bio',
callback: function cb(err, data) {
if (err) {
console.error(err)
}
// do something with data
},
})
My favorite way of writing APIs is using the fluent API by method chaining.
Method chaining is simply the process of chaining multiple calls one after the other. The general idea is to achieve a readable and fluent code, thus making it quicker to understand. These methods are commonly verbs (like rotate)
For example:
getPhoto('../nemo_the_fish.jpg')
.applyFilter('grayscale', '100%')
.rotate(100)
.scale(1.5)
This translates to: "retrieve the image nemo_the_fish.jpg and apply the grayscale filter with a value of 100%, rotate the image by 100 degrees and increase the scale by 1.5 times more."
A good thing about this practice is that it's very quick to get started with writing your own fluent API interface. You would simply return the reference to the context inside your method calls so that it can be chained:
const createWarrior = function createWarrior(name) {
let hp = 100
let battleCryInterval = 0
return {
bash: function(target) {
target -= 10
return this
},
// Increase the wrarior's health by 60, decrementing it by 1 every second for 60 seconds
battleCry: function battleCry() {
hp += 60
battleCryInterval = setInterval(() => {
hp -= 1
}, 1000)
setTimeout(() => {
if (battleCryInterval) {
clearInterval(battleCryInterval)
}
}, 60000)
return this
},
getHp: function getHp() {
return hp
},
}
}
const warrior = createWarrior('chris')
const otherWarrior = createWarrior('bob')
warrior
.battleCry()
.bash(otherWarrior)
.bash(otherWarrior)
.bash(otherWarrior)
.bash(otherWarrior)
.bash(otherWarrior)
const otherWarriorsHp = otherWarrior.getHp()
console.log(otherWarriorsHp) // result: 100
One of the greatest examples of a fluent API is jQuery, and thanks to the library's fluency it arguably makes it one of the easiest JavaScript libraries to both learn and use:
$(window).resize(function() {
$('#logbox').append('<div>The window resized</div>')
})
However, the method chaining fluent API comes with a few drawbacks.
The biggest drawback is that it can be difficult to set a breakpoint in the middle of a chain, making errors difficult to debug. In addition, it encourages too much procedural code. There are ways to get around the debugging issue though by inserting loggers at any step in the chain and using them to call subsequent methods with it.
Another drawback is that you can get caught up in the act of writing long sentences with tight dot notation access. This can get in the way of keeping things simple, so remember to keep things simple.
You might have heard of the term polymorphism in other languages, generally where something behaves differently based on the context.
The same concept applies in function polymorphism in JavaScript. These are functions that behave accordingly to the arguments passed in (which is our context).
APIs often gather arguments to an array or array-like structure in order to have more control over them. Having them into an array structure allows them to do things like pass them into other functions in the same scope and vice versa.
Before arrow functions were introduced, the common practice to gather arguments inside polymorphic functions was using the array-like arguments
object. Sometimes you might be in situations where you need to do more things with the arguments after you assigned them to an array. Even though arguments is an array-like object, it doesn't really function like a real array because it is missing essential array functions--and this is very limiting.
The way developers get around that is to make a separate, shallow copy by using Array.prototype.slice.call()
. This is called method delegation
. In order words, you delegate the slice() call to the Array.prototype
object.
An example of this would look like this:
const args = Array.prototype.slice.call(arguments, 0)
This will copy the items starting at index 0 and return everything onwards.
Arguments doesn't have real methods like .push
or .shift
, so we convert it to an array with Array.prototype.slice
so that we can get access to all of the array methods.
In ES6, we can easily convert it to an array by using the spread operator as shown below:
const someFunction = function(...args) {
console.log(args)
console.log(args.shift())
}
someFunction(1, 'hello', 'bob')
// result:
// [1, "hello", "bob"]
// 1
When you have your arguments into an array or array-like structure, you can determine where to go with the execution logic based on how the arguments look like. This makes it very flexible to be used for multiple purposes without writing too much code.
Without spreading:
const applyFilter = function(filter, value, options) => {
const args = [].slice.call(arguments, 0)
console.log(args.length) // result: 2
}
applyFilter('grayscale', '100%')
With spreading:
const applyFilter = (...args) => {
console.log(args.length) // result: 1
}
applyFilter('grayscale', '100%')
With this in mind, we can now determine how to handle the execution from these arguments:
const applyFilterToImage = (image) => {
return function applyFilter(...args) => {
// we can also grab args with [].prototype.slice.call(arguments, 0)
let options
let filters = {}
let callback
const arg1 = args[0]
// The caller wants to apply multiple filters
if (args.length === 1) {
if (arg1 && typeof arg1 === 'object') {
filters = { ...arg1 }
// Find out of the caller wants the new image with applied filters back by checking if a callback was passed in
const arg2 = args[1]
if (arg2 && typeof arg2 === 'function') {
callback = arg2
}
} else {
throw new Error(
'You must supply an object if you are only providing the first argument',
)
}
} else {
if (args.length > 2) {
// The caller passed in options as the third argument
if (typeof args[3] === 'object') {
options = args[3]
}
// The caller provided a callback function and wants the image with applied filters passed back
else if (typeof args[3] === 'function') {
callback = args[3]
}
}
// The caller wants to apply one filter
if (typeof arg1 === 'string') {
const filter = arg1
const value = args[1]
filters[filter] = value // or filters = { [filter]: value }
} else {
if (callback) {
callback(new Error('Filter is not a string'))
}
}
}
const newImg = api.filterImage(filters, options)
if (callback) {
return callback(null, newImg)
}
}
}
const img = '../bob_the_builder.jpg'
const applyFilter = applyFilterToImage(img)
const callback = (newImg) => {
console.log(newImg)
}
applyFilter({
grayscale: '100%',
rotate: 100,
scale: 1.5,
}, callback)
The simple function allows the developer to use it in multiple ways:
That concludes the end of this article. Look out for more posts from me in the future!