JS default values in ES2015+
By Christophe Porteneuve • Published on 16 February 2022 • 4 min

Cette page est également disponible en français.

Welcome to the sixth installment of our Idiomatic JS series.

This concludes our “Holy Trinity” part: after destructuring and rest and spread, here come the default values!

Default values have been around for a long time in many popular languages, often well before JavaScript added them in 2015. It was well worth the wait thoough: JavaScript default values pack a lot more punch!

What are default values useful for?

We use them to formalize default data values and to limit when they are applied.

They can be used anywhere there an implicit assignment, which means:

  • Function signatures (parameters are implicitly assigned arguments at matching positions), and
  • destructurings (their elements are implicitly assigned matching elements from the destructured source).

As you likely expect, the operator is =, and they are only triggered on undefined.

How did we do before them?

This is very different from how things were with the traditional hack for default values, that most of the time relied on the **logical OR** (||), hence on boolean coercion 😅 of the original value:

// Old-school, and not so good.
function repeat(term, times, separator) {
times = times || 1
separator = separator || '-'

}

With that older way, any falsy value was ignored and led to the default value. This would cause issues when some of the falsy values were legit: for instance, in the example above, a times of zero (0 || 1 is 1, as 0 is falsy) or an empty separator ('' is falsy too).

True, it could be useful to handle in one go many values deemed invalid (such as undefined and null, perhaps NaN too), which won’t be possible with the default values syntax, but most of the time, focusing on undefined is more appropriate.

Readability and code completion

Besides, a bit like position-based rests within a signature, formal default values provide extra information within the signature about the behavior and expectations of our function, in addition to trimming its early code:

// Better
function repeat(term, times = 1, separator = '-') {

}

Using this comes with significant benefits:

  • The signature is more informative and explicit.
  • Completion systems and argument info popups in editors and IDEs often surface that information.
  • The beginning of the function is no longer cluttered with “argument massaging,” if only to handle their default values.

Any expression, even backrefs!

On the right-hand side of the equal sign, you can type any expression at all, including ones with function calls, which is seldom possible outside of JS. Until we get throw expressions, some use that to make elements “mandatory” at runtime by providing a default value that throws an exception:

function banner(title = required('title'), banner = '-'.repeat(42)) {

}

// Using that kind of utility:

function required(arg) {
throw new SyntaxError(`${arg} is required`)
}

Within the default value’s epxression, you can even re-use terms from earlier in the list. Again, few languages let you do that in their default values. Here’s a sweet example:

function banner(title, line = '-'.repeat(title.length)) {

}

Here the second argument’s default value is aligned to the first argument’s text length (well, except if we start playing with Unicode, but we could easily fix that). When I call banner('hello'), within the function, the line argument will be '-----' by default. Lovely!

Another little example I love comes from a time when I had to write a function that extracted the opening and closing delimiters of tagged content and return an array of the opener and closer, except when they were identical instead of symmetrical (as are <>, (), [] and {}), in which case the array had to be single-element:

getSurroundingTags('<hello>') // => ['<', '>']
getSurroundingTags('[hello]') // => ['[', ']']
getSurroundingTags('@hello@') // => ['@']
getSurroundingTags('`hello`') // => ['`']

Something though I would want to use both cases in a unified way, with the opener and closer, even when they were identical. The ability to use backrefs for default values let me write that in a concise, intentional way:

const [opener, closer = opener] = getSurroundingTags(text)

I love it 😍!

Best practices of signature design

In May 2020, in our 19 JavaScript nuggers series, I had discussed how to cleanly define optional named parameters; as it relies on name-based destructuring and default values, allow me to reiterate here.

Nobody likes that kind of function signatures:

// Extreme but *real* case (older DOM Events API)
// (See how Prettier makes that horror OBVIOUS by going single-arg-per-line?)
initMouseEvent(
'click',
true,
true,
null,
null,
100,
100,
30,
42,
false,
false,
false,
false,
0,
null
)

// Simpler — but still confusing — case
new Node('Intro', true, true)

Signatures with many parameters that are unintuitive and use the same data type are practically unusable: they pose a dire maintenance threat. We much prefer using named parameters. JS doesn’t have dedicated syntax for that (unlikeRuby, Swift, Kotlin, PHP 8 or C#, to name only these), but we can get reasonably close with name-based destructuring:

class Node {
constructor({ title, focusable = true, activable = true }) {}
}

// Single-line def
function banner({ text, bannerChar = '-', bannerSize = bannerChar.repeat(text.length) }) {

}

// Multi-line def (Prettier takes care of that anyway)
function banner({
text,
bannerChar = '-',
bannerSize = bannerChar.repeat(text.length) }
}) {

}

This makes our calls a lot nicer to read, they sort of “self-document:”

new Node({ title: 'Intro' })
new Node({ title: 'Intro', focusable: false })

banner({ text: 'hello', bannerSize: 42 })

That’s a lot better!

That being said… what if all arguments are optional? Either because they all have an explicit default value, or because those that don’t wouldn’t be used when undefined? As our signature stands, we have a problem. Check it out:

function formatDate({ date = new Date(), style = 'medium' }) {
return date.toLocaleString('en-GB', { dateStyle: style })
}

Besides being suboptimal for repeat calls (it instantiates a fresh Intl.DateTimeFormat every time under the hood), should we wish to call this function in “100% defaults” mode, how would we? We’d like to be able to write this:

formatDate() // => KABOOM! (TypeError: Cannot read properties of undefined (reading 'date'))

That breaks because we try to destructure our argument, and as we didn’t pass any, we attempt to destructure undefined, which is not allowed. We would have to swallow this down:

formatDate({}) // Works, but 😪

To enable an empty call, all we need to do is supply a default value for the argument itself! Look at the end of the signature below:

function formatDate({ date = new Date(), style = 'medium' } = {}) {
return date.toLocaleString('en-GB', { dateStyle: style })
}

This way, when we pass zero argument, it will use an empty object; we then proceed to granularly apply default values within the destructuring for every “named” parameter. Cool 😎.

There’s always more!

We have many wonderful topics left in this series.

Besides our wealth of other articles, you may wish to look at our live training courses! In particular, if you like super-deep dives into JavaScript itself, we heartily recommend our 360° ES course!