Properly defining optional named parameters
Published on 15 May 2020 • 4 min

Cette page est également disponible en français.

Welcome to the twelfth post of our daily series “19 nuggets of vanilla JS.” Have you heard of “named parameters,” also known as “keyword parameters?” They’re very handy but don’t exist per se in JavaScript. Fortunately, we’ve had ways to tackle this for a long time, that have become even easier with ES2015…

The series of 19

Check out surrounding posts from the series:

  1. Properly sorting texts
  2. Extracting emojis from a text
  3. Properly defining optional named parameters (this post)
  4. const is the new var
  5. Using named captures
  6. …and beyond! (fear not, all 19 are scheduled already)…

No “actual” named parameters…

Many languages feature something commonly referred to as “keyword parameters” (or named parameters), that provide much-improved ergonomics as they let us:

  • name the arguments we pass, clarifying the call site;
  • only pass the arguments we need;
  • not rely on a specific argument order when writing the call.

Here’s an example in Ruby:

def smart_slice(from:, to: -1)
# We've got two named parameters here:
# - `from`, that is required
# - `to`, that would default to -1
end

# Only the mandatory arg
smart_slice(from: -5)

# Both args (order is irrelevant)
smart_slice(from: -3, to: -2)
smart_slice(to: -2, from: -3)

You can find these in Kotlin, Python, Swift… In C#, any argument can be named at call time (as long as there are no anonymous arguments following it). Long story short: it’s a common feature.

JavaScript doesn’t really have that. When calling a function, we simply pass arguments in the same order as parameters were defined: the pairing is implicit and position-based.

Even when not falling into signatures from Hell, this can quickly devolve into quite unreadable stuff:

// Even if you know you need two booleans, which is which?
setup(true, false)
// 15,15 is likely the center, but then? Radius? Stroke width?
new Circle(15, 15, 8, 3)

A rule of thumb states that when you have many consecutive arguments of the same type with no domain-natural ordering, or when you exceed 3 arguments (even intuitive ones), you should name your parameters. OK then, but how do we achieve that in JS?!

The options hash, a time-honored solution

Before Ruby formally had keyword parameters, it cheated by using a final Hash-type argument, as the language’s syntax then let us skip the curly braces of the literal Hash at call time. In JavaScript, we’ll use an options hash too (an object literal, to be blunt), except curlies are required.

We’ve done this for a long time. After all, jQuery.ajax(…) sports 35 options, can you imagine how ugly calls would get if they were provided positionally?

When we did that the old way, this was quite cumbersome:

// OLD SKOOL
function run(options) {
// Now to massage args…
options.timeout = options.timeout == null ? 10 : options.timeout
options.onSuccess = options.onSuccess || noop
options.onError = options.onError || noop
// Now for the operational code, at last!
}

run({ timeout: 5, onError: console.error })

Besides producing verbose code, the signature lacks any useful information; if we didn’t have detailed docs (or a well-crafted type definition), we’d be screwed and would have to rummage in the source code, looking for clues. Plus, said code starts with “noise” instead of operational code.

Named destructuring of the argument

The advent of named destructuring in ES2015 certainly helped make things a bit clearer:

// LESS OLD SKOOL
function run({ timeout, onSuccess, onError }) {
timeout = timeout == null ? 10 : timeout
onSuccess = onSuccess || noop
onError = onError || noop
// Operational code
}

At least the signature was a bit more descriptive (and we’ll get better autocompletion, etc.). Even without a detailed type definition, most EDIs and decent editors would provide completion on option names.

Default values

ES2015 also brings default values to the table. These are used if and only if (“iff”) the origin data is undefined, which is usually cool, but not always what we need. For instance, if you regard null, 0 or false as invalid, you’ll need to manually massage these arguments some more. But still, that’s pretty cool:

// NEW SCHOOL
function run({ timeout = 10, onSuccess = noop, onError = noop }) {
// Operational code
}

Again, this information will surface in autocompletion when writing the call, which always comes in handy.

What if I don’t want to pass anything?

A common pitfall happens when your function should, in practice, allow a call with no arguments. This would happen if all your named options feature a default value (or for those that don’t, undefined is considered acceptable). You’d then like to allow empty calls:

run()
// => TypeError: Cannot read property 'timeout' of undefined

The issue here is that the signature destructures its argument, but you can’t destructure null or undefined, hence the TypeError.

Do note that if even one of your arguments is mandatory (e.g. timeout), on the one hand you won’t put a default value to it, and on the other hand, that type of crash on an empty call likely becomes legit.

But how could we allow an empty call? We only need to provide a default value for the argument itself:

function run({ timeout = 10, onSuccess = noop, onError = noop } = {}) {
// Operational code
}

Here, as no object was passed for the argument, it will default to the empty object, and since that doesn’t have any of the properties our options need, their individual default values will be used.

Why put the default values in the destructuring?

I sometimes get asked why we should put the default values inside the destructuring, instead of on the higher-level default value for the whole argument, like so:

// DON’T DO THIS!
function run(
{ timeout, onSuccess, onError } = {
timeout: 10,
onSuccess: noop,
onError: noop,
}

) {
// Operational code
}

Besides repeating option names, this spells trouble when you do pass some of the options in: your (partial) options object will replace the default object. The destructuring will not provide default values for missing options.

run({ onError: console.error })
// timeout and onSuccess end up `undefined`. Bummer.

So always put your options’ default values inside the destructuring. Plus, it’s shorter.