How to Create Flexible JavaScript APIs with Functional Options

How to Create Flexible JavaScript APIs with Functional Options

·

13 min read

The methods presented in this article were popularized by Dave Cheney, Rob Pike, and Márk Sági-Kazár. This article presents how to adapt these methods to JavaScript.

Functional Options is a term used in the Go developer community and was created to explicitly describe and set an API's configuration options.

Go is a statically typed programming language, while pure JavaScript is not, therefore not every functional options method can be converted to JavaScript, nonetheless, it still offers a good way of defining an application API configurations.

Traditional way of passing arguments

Let's look at the "traditional" way of setting up default configuration options for a method. Say we develop a conference meet application and we have the following function for creating a new meet.

function CreateMeet(name, startDateTime) {
   console.log(name, startDateTime)
}

We initialize the function above like so.

CreateMeet('Meeting', new Date())

From a developer perspective, it's not really obvious what arguments the function expects without looking at the function's signature. Also, this is a trivial example, but if the function has complex initialization arguments, not just JavaScript primitives, it falls short very quickly.

Not to mention that it makes our function inflexible for modification, adding a new argument would mean we need to modify all the CreateMeet() function calls in our code, or worse, we easily introduce backward-incompatible changes in our JavaScript module.

Passing an object literal

Thinking about the problem differently, we could modify the function signature and use an options object literal to pass our options to the function.

function CreateMeet(options) {
   console.log(options.name, options.startDateTime);
}

This fails horribly because if we pass an object other than what CreateMeet expects or if we’re not passing anything at all. Without proper validation, executing the function will throw an error.

One fix we could do is to define some sensible defaults and merge our options with the default options.

function CreateMeet(options) {
  const defaultOptions = {
    name: 'No Name',
    startDateTime: new Date()
  }

  options = {
    ...defaultOptions,
    ...options
  }
}

Again, without validating options we could merge a totally unrelated object literal with defaultOptions.

Nonetheless, it is a good way of making sure the passed options argument contains all the properties that the function might need and this solution is enough most of the time, but it's not the CreateMeet function's job to make sure the options are correct.

Another problem with the solution above is that it's not very reusable in a complex application, where the options are maybe defined in other parts of the code, consider how we would execute this function:

CreateMeet({
  name: 'My Meet',
  startDateTime: new Date(2021,0,6,13,15,0,0)
})

This type of configuration initialization falls short if we have many configuration options that our function does not necessarily care about, and if we want to validate for correct values too; or if we want to define required options.

Passing in variables and object literals

One could argue we could write something like this where the name is explicitly defined...

function CreateMeet(name, options) {
  ...
}

...but then we circled back to our original problem where every function argument was explicitly defined making it inflexible for future modifications.

Passing in variadic variables

An alternative solution we could implement is using variadic function arguments.

function CreateMeet(...options) {
  console.log(options)
}

With this approach, ...options becomes an array of JavaScript primitive types, but we would still need to validate each individual option item in the array to make sure the correct option is passed to our function.

Passing in variadic functions

Variadic function arguments to the rescue! In this solution we could just pass in functions for ...options and to make sure that we only accept functions as arguments.

function CreateMeet(...options) {
  options.forEach((opt) => {
    if ( typeof opt !== 'function' ) { return }
    ...
  })
}

In the function above if the ...options item is not of type function it will continue to iterate to the next item.

Okay, but what's the purpose of this? Well, we could pass in our specific options literal to the option functions that are passed as arguments which in turn validate and modify our options literal, and removing this concern from our CreateMeet function.

Consider the following option function that would be passed to CreateMeet.

function Name(value) {
  return (options) => {
    options.name = value
  }
}

So what's happening here? The Name is an "option function" which, in turn, returns a function accepting our options literal from CreateMeet. Let's modify CreateMeet to understand it more clearly.

function CreateMeet(...options) {
  let config = {
    name: '',
    startDateTime: null
  }

  options.forEach((opt) => {
    if ( typeof opt !== 'function' ) { return }
    opt(config)   
  })

Executing CreateMeet would look like this.

CreateMeet(
  Name('My Meet')
)

Passing in Name as an argument, which, remember, returns a function, and this returned function from Name would be executed in CreateMeet with opt(config) where config is our configuration object literal that we actually care about.

Let's define a startDateTime function option to better understand this method.

function StartDateTime(year, month, date, hour, minute) {
  return (options) => {
    // We don't care about defining seconds and milliseconds so we pass 0 to new Date()
    // In JS month starts at 0, but we would like to define 1 - 12 (January through December), this is why we subtract 1.
    // Also, proper validation is in order, this is just a simple example
    month = (month - 1 <= 0) ? 0 : month - 1
    options.startDateTime = new Date(year, month, date, hour, minute, 0, 0)
  }
}

Passing in these function arguments to CreateMeet would look like this.

CreateMeet(
  Name('My Meet'),
  StartDateTime(2021, 1, 6, 13, 15)
)

This makes our function much more readable to other developers, we instantly know that CreateMeet is executed by defining a Name and StartDateTime.

Furthermore, we could extract the initialization of the options altogether from CreateMeet into a separate function such as this, which not necessarily need to be exported.

function setupConfig(...options) {
  let config = {
    name: '',
    startDateTime: null
  }

  options.forEach((opt) => {
    if ( typeof opt !== 'function' ) { return }
    opt(config)   
  })

  return config
}

Now, CreateMeet would only execute code that it cares about.

function CreateMeet(...options) {
    const config = setupConfig(...options)

    // do something with config
    console.log(config)
}

Extending CreateMeet

Extending our CreateMeet function becomes trivial with this approach.

Let's say we want to add another option to our function, but still want to ensure backward compatibility. We want to add the option of allowing only specific users, from a list, in the meet, thus executing CreateMeet will handle this scenario correctly.

Our AllowedUsers function option could look like this.

function AllowedUsers(userList) {
  return (options) => {
    options.allowedUsers = userList
  }
}

Passing in this new option function is as easy as adding a new argument to CreateMeet

CreateMeet(
  Name(‘My Meet’),
  StartDateTime(2021,1,6,13,15),
  AllowedUsers([‘john’, ‘jane’])
)

Keep in mind that the public API of our function hasn't changed, the previous examples work the same way with or without AllowedUsers being passed to CreateMeet.

We can go as far as to add different methods to manipulate the same option, in this example, AllowedUsers only accepts a user list and then overwrites the configuration with that list.

Let's say, down the road, in a future version of our application, we'll want to add a function that accepts a single user name only. In this case, we could write a new function like this.

function AllowedUser(userName) {
  return (options) => {
    options.allowedUsers.push(userName)
  }
}

Executing CreateMeet works as expected, end users can use either AllowedUsers (plural) to pass in a user list or AllowedUser (singular) to append a user name to an existing list.

Conclusion

We, as developers, should be very aware of how the public-facing API of our code is being consumed by other users.

This technique helps to keep this API flexible enough for future modifications and it's just another technique in the arsenal of a developer.

Should you use it every time? Probably not, in most cases passing a configuration object literal is enough, but if you have complex configuration setups, want greater flexibility, and also extracting the configuration setup from functions that don't care about it, then this approach is a good fit.

I hope you enjoyed this article, please comment and consider sharing it.

If you have any questions you can contact me here in the comments or on Twitter.

Below you'll find the full example presented in this article as well as a Codepen demo.


Full Example

function Name(value) {
  return (options) => {
    options.name = value
  }
}

function StartDateTime(year, month, date, hour, minute) {
  return (options) => {
    month = (month - 1 <= 0) ? 0 : month - 1
    options.startDateTime = new Date(year, month, date, hour, minute, 0, 0)
  }
}

function AllowedUsers(userList) {
  return (options) => {
    options.allowedUsers = userList
  }
}

function AllowedUser(userName) {
  return (options) => {
    options.allowedUsers.push(userName)
  }
}

function setupConfig(...options) {
  let config = {
    name: '',
    startDateTime: null,
    allowedUsers: []
  }

  options.forEach((opt) => {
    if ( typeof opt !== 'function' ) { return }
    opt(config)   
  })

  return config
}

function CreateMeet(...options) {
    const config = setupConfig(...options)

    // do something with config
    console.log(config)
}

CreateMeet(
  Name('My Meet'),
  StartDateTime(2021, 1, 6, 13, 15)
)

CreateMeet(
  Name('Private Meet'),
  StartDateTime(2020, 1, 6, 14, 0),
  AllowedUsers(['john', 'jane'])
)

CreateMeet(
  Name('One-on-one Meet'),
  StartDateTime(2021, 1, 6, 14, 30),
  AllowedUser('kevin')
)

Codepen Example