This article is all about the new features added to JavaScript via ECMAScript 6 to improve the handling of function parameters. More specifically we'll be talking about default parameters, rest parameters using the rest operator, the spread operator, and finally destructured parameter values. We'll also be using features previously covered in our discussion about arrow functions, block-level scoping and destructuring, so if you're unfamiliar with those topics you may want to read up on them first.
TL;DR
ES6 allows for function headers to define default values for parameters, marking them as optional:
function getData(data, useCache = true) {
if (useCache) {
console.log('using cache for', data)
} else {
console.log('not using cache', data)
}
}
// `useCache` is missing and is `undefined`.
// therefore `useCache `defaults to `true`
getData({ q: 'churches+in+Pittsburg' })
Rest parameters should complete replace the need for the problematic arguments
special variable:
function join(separator, ...values) {
return values.join(separator)
}
// all of the parameters after the first
// are gathered together into `values`
// which is a true `Array`
// output: "one//two//three"
console.log(join('//', 'one', 'two', 'three'))
We should no longer need the apply
function with the new spread operator:
function volume(width, length, height) {
return width * length * height
}
// the array values are separated into
// separate parameters
// output: 80 (2 * 8 * 5)
console.log(volume(...[2, 8, 5]))
Lastly, object destructuring with function parameters allows us to simulate named parameters:
let ajax = function (url, { method, delay, callback }) {
console.log(url, method, delay)
setTimeout(() => callback('DONE!'), delay)
}
// the second parameter to the function
// is an object whose properties are
// destructured to individual variables
// simulating named parameters
ajax('http://api.eventbrite.com/get', {
delay: 2000,
method: 'POST',
callback: function (message) {
console.log(message)
},
})
These quick examples are just a tip of the iceberg. Be sure to check out the full suite of parameter handling code examples (a part of the Learning ES6 Github repo) and keep reading.
Default parameters
JavaScript allows for functions to be called with less parameters than the function declares. In ES5, this ability was leveraged to implicitly support optional parameters. Here's an ES5 version of the ES6 example used above:
function getData(data, useCache) {
if (useCache === undefined) useCache = true
if (useCache) {
console.log('using cache for', data)
} else {
console.log('not using cache', data)
}
}
getData({ q: 'churches+in+Pittsburg' })
As you can see, useCache
is declared as a parameter of the getData
function, but the caller didn't pass a value for it. As a result the JavaScript engine passes undefined
, which the getData
checks for in order to default the value to true
.
Once again the ES6 equivalent looks like:
function getData(data, useCache = true) {
if (useCache) {
console.log('using cache for', data)
} else {
console.log('not using cache', data)
}
}
// `useCache` is missing and is `undefined`.
// therefore `useCache `defaults to `true`
getData({ q: 'churches+in+Pittsburg' })
With ES6, the function body doesn't have to deal with the defaulting logic. It's in the function header, which is where it should be. Also it's clear to any readers of the code that useCache
will have its value defaulted to true
when left unspecified.
Parameters with a default value are considered optional. Therefore if you have a parameter that is optional, but doesn't have a default value, you may want to consider giving it an explicit default of undefined
to visually mark it as optional.
Required parameters
In the example, the data
parameter is considered required since it does not have a default value. However, the JS engine will not throw an error if you leave a required parameter unspecified because of backwards compatibility support. In ES5 to enforce required parameters, you would look at the length of arguments
or check if the parameter was undefined
and throw an Error
.
There are a few ways you can implement required parameters in ES6 yourself, but all of them add visual clutter. The best (and most clever) approach comes from Axel Rauschmayer (via Allen Wirfs-Brock):
/**
* Gets called if a parameter is missing and the expression
* specifying the default value is evaluated.
*/
function throwIfMissing() {
throw new Error('Missing parameter')
}
function func(requiredParam = throwIfMissing()) {
// some implementation
}
If requiredParam
is unspecified or undefined
, an Error
will be thrown, which is exactly what we want. It's just a little weird to have a function as your default value which doesn't return a value, but only throws an Error
. Speaking of function calls as default values...
Non-primitive default values
Unlike other programming languages that support default values (like C#), a default value in ES6 does not have to be a primitive value like String
, Number
or Boolean
. A default value can be an Object
, Array
or Function
. The default value can even be the result of an expression or function call.
function getWidth() {
console.log('getWidth called')
return 7
}
function drawRect(
width = getWidth(),
height = width * 2,
options = { color: 'red' },
) {
console.log(width, height, options)
}
// `getWidth` is called to retrieve default
// value for `width` since it was unspecified.
// output:
// getWidth called
// 7, 14, {color:'red'}
drawRect()
// `getWidth` is not called because `width` is
// specified. `height` is still defaulted to
// 2x `width`.
// output:
// 17, 34, {color:'red'}
drawRect(17)
// `height` is no longer defaulted to 2x `width`
// but options are still defaulted.
// ouput:
// 4, 11, {color:'red'}
drawRect(4, 11)
// nothing is defaulted
// output:
// 7,5, 11, {color:'blue'}
drawRect(7.5, 11, { color: 'blue' })
Pretty cool, huh? And did you notice how the default value for height
is an expression that uses the value of width
? You're free to use other variables declared in the function header as long as they come before the variable in question. It's also worth noting that expressions or functions are not executed if the variable does not need to be defaulted.
Default parameter ordering
Also unlike other programming languages, in ES6 the default values can be anywhere in the function header, even before parameters that do not have default values.
Let's take a look at an example:
function drawCube(x, y = 7, z) {
console.log('cube', x, y, z)
}
// `y` is defaulted, but `x` & `z` are not
// so they are `undefined`.
// output: cube, undefined, y, undefined
drawCube()
// `y` is still defaulted, but `z` isn't.
// output: cube, 2.5, 7, undefined
drawCube(2.5)
// output: cube, 9, 15, undefined
drawCube(9, 15)
// output: cube, 4, 1.7, 18
drawCube(4, 1.7, 18)
// `y` is once again defaulted
// output: cube, 11, 7, 8.8
drawCube(11, undefined, 8.8)
// `null` does not trigger `y` to default
// output: cube, 14, null, 72
drawCube(14, null, 72)
As you can see, the default for y
isn't triggered unless y
and z
are unspecified or y
is explicitly set to be undefined
. In a nutshell, undefined
triggers default values.
It may not be immediately clear why TC39 chose to have undefined
trigger default values. The reasoning can best be explained with a couple of examples.
First take a look at this example from Rick Waldron’s TC39 meeting notes (July 24, 2012):
function setLevel(newLevel = 0) {
light.intensity = newLevel
}
function setOptions(options) {
// Missing properties in `options` will
// result in `undefined` being passed to
// `setLevel` which will trigger default value
setLevel(options.dimmerLevel)
// more code here...
}
setOptions({ speed: 5 })
We don't have to generate a default value for options.dimmerLevel
within setOptions
. Instead because undefined
triggers default values and missing properties on objects are undefined
, we can delegate the value defaulting to setLevel
.
Another example taken from Axel Rauschmayer's book Exploring ES6:
function multiply(x = 1, y = 1) {
return x * y
}
function square(x) {
return multiply(x, x)
}
// `x` will be `undefined` and defaulted
// for `x` and `y` in `multiply`
square()
The square
function doesn't have to worry about defaulting x
and can delegate that task to multiply
. Because x
is left unspecified in the call to square
, its value is undefined
. And since undefined
is passed to multiply
for x
and y
, their values are defaulted.
Default parameters and arrow functions
Last thing on default parameters. They also can also be used with arrow functions!
// 2nd parameter is `undefined`, triggers
// default of 100.
// output: 2, 200, 10
console.log([1, undefined, 5].map((x = 100) => x * 2))
If you recall in the article on arrow functions, we learned that when arrow function just has one parameter that's an identifier we can omit the parentheses around it. However, that rule only applies when that parameter is only an identifier. If it has a default value, as in our example above, parenthesis are needed.
Rest parameters
We just learned that default parameters handle the case where a caller passes less parameters than what a function declares. JavaScript also allows for functions to be called with more parameters than the function declares. That's where rest parameters come in.
A common use-case where a function will have less parameters declared is when the function can take an arbitrary number of parameters. Let's say we wanted to write a join
function similar to the join
method for Array
except we want to specify individual parameters instead of an array.
With ES5 we would implement like so:
function join(separator) {
var values = []
for (var argNo = 1; argNo < arguments.length; argNo++) {
values.push(arguments[argNo])
}
return values.join(separator)
}
// output: "one++two++three"
console.log(join('++', 'one', 'two', 'three'))
The arguments
special variable is problematic for many reasons; one being that it's not an actual Array
object, so methods like slice
are unavailable to use. Also because we have the separator
parameter, we have to start at index 1
of arguments
, which is pretty annoying. Lastly, just looking at our join
function, it's not immediately discoverable that it actually takes more than one parameter, let alone that it supports an infinite number of them.
ES6 introduces the rest operator, three dots (...) that precede a named parameter. That parameter is now a rest parameter that is an Array
containing the rest of the parameters (hence the name!).
Here's join
rewritten using ES6 rest parameters:
function join(separator, ...values) {
return values.join(separator)
}
// all of the parameters after the first
// are gathered together into `values`
// which is a true `Array`
// output: "one//two//three"
console.log(join('//', 'one', 'two', 'three'))
In the example, values
is an Array
of all of the parameters passed after '//'
making it much easier to use. Also, it's much clearer looking at the join
function that it does take an additional unlimited set of parameters.
One rest parameter per function
One caveat with rest parameters is that unlike default parameters there can only be one per function and it must be the last parameter declared in the function header. Attempting to have multiple rest parameters or putting one before other parameters will throw a SyntaxError
:
function afterRest(first, ...second, third) {
// SyntaxError: parameter after rest parameter
}
function multipleRest(first, ...second, ...third) {
// SyntaxError: parameter after rest parameter
}
If you're using a transpiler, you will get an error when trying to transpile your ES6 code down to ES5.
Enforcing maximum arity
ES6 unfortunately doesn't provide a mechanism for enforcing a maximum arity (number of passed parameters) of a function. However, you can leverage rest parameters to hack around the lack of support.
function max(...values) {
// only want as many a 3 parameters
// so throw error if over
if (values.length > 3) throw Error('max 3 parameters allowed!')
// use destructuring to get values
// into variables
let [a, b, c] = values
return Math.max(a, b, c)
}
// not an error
// returns 3
max(1, 2, 3)
// error!
max(1, 2, 3, 4)
The problem with this approach is that the function actually wants to define a
, b
and c
as its parameters, but because it needs to do arity validation, those variables are instead assigned in the function body using destructuring.
We could clean things up a little bit:
function max(a, b, c, ...shouldBeEmpty) {
if (shouldBeEmpty.length > 0) throw Error('max 3 parameters allowed!')
return Math.max(a, b, c)
}
// not an error
// output 6
max(4, 5, 6)
// error!
max(4, 5, 6, 7)
This is a little better, but introduces a 4th parameter, shouldBeEmpty
, that's not intended to be a part of the actual code, which could be confusing.
This may be the one (and only) case where using arguments
may be preferable over a rest parameter. In pretty much every other case, rest parameters should replace uses of arguments
.
Spread operator
While rest parameters use the rest operator to combine zero or more parameters into a single array parameter, the spread operator does just the opposite. It separates an array into zero or more parameters.
But before we get into how the spread operator works, lets first take a look at the ES5 code it is intending to replace.
function merge() {
var masterObj = {}
// iterate over `arguments` merging each
// into `masterObj` to generate flattened
// object
for (var i = 0; i < arguments.length; i++) {
var obj = arguments[i]
for (var key in obj) masterObj[key] = obj[key]
}
return masterObj
}
let merged = merge(
{
count: 5,
delay: 2000,
early: true,
message: 'Hello',
},
{
early: false,
},
)
// output:
// {count:5, delay:2000, early:false, message:'Hello'}
console.log(merged)
The merge
function is designed to take an arbitrary number of objects and flatten them into one object. Calling merge
is easy when you have individual objects, but what happens when you want to flatten array of objects? You have to use apply
:
var objectsList = [
{
count: 5,
delay: 2000,
early: true,
message: 'Hello',
},
{
early: false,
},
]
var merged = merge.apply(undefined, objectsList)
// output:
// {count:5, delay:2000, early:false, message:'Hello'}
console.log(merged)
This works okay. For those of us JavaScript ninjas this sort of thing is old hat. But the code is a bit weird, especially the fact that we have to pass undefined
as the first (context) parameter. And then there's always the confusion between apply
and call
. The former takes an array, while the latter takes an unbounded list of parameters.
Now instead of apply
, we can use the spread operator (along with a rest parameter!):
function merge(...objects) {
let masterObj = {}
// iterate over `objects` merging each
// into `masterObj` to generate flattened
// object
for (let i = 0; i < objects.length; i++) {
let obj = objects[i]
for (let key in obj) masterObj[key] = obj[key]
}
return masterObj
}
let merged = merge(...objectsList)
// output:
// {count:5, delay:2000, early:false, message:'Hello'}
console.log(merged)
The spread operator looks exactly like the rest operator. It as the same three dots (...). The only difference is that it is used in function calls and array literals instead of function parameter declarations. The spread operator should be able to replace the majority, if not all, uses of apply
.
At first, using the spread operator may not seem like much of an improvement over apply
, besides no longer having to specify undefined
. However, the spread operator can be used anywhere in a function call and may be used more than once as well. Take a look at this example:
let merged = merge({ count: 10 }, ...objectsList, { delay: 1500 })
// output:
// {count:5, delay:1500, early:false, message:'Hello'}
console.log(merged)
Now we're specifying individual objects as well as the array. If we were going to still try to use apply
we would first have to build a new array including the individual objects. Spread operator to the rescue!
Spread operator and arrays
The spread operator doesn't only work with function calls. It can also be used to simplify array manipulation.
For example we can turn this in ES5:
// ES5
var list = [9, 8, 7, 6, 5],
first = list[0],
second = list[1],
rest = list.slice(2)
// output: [7, 6, 5], 8, 9
console.log(rest, second, first)
To this in ES6 using the spread operator with array destructuring:
// ES6
let list = [9, 8, 7, 6, 5],
[first, second, ...rest] = list
// output: [7, 6, 5], 8, 9
console.log(rest, second, first)
We can replace the slice
call as well as the individual array indexing into a simple destructure pattern.
But wait, there's more!
// ES5
;[11, 10].concat(list)
// ES6
;[11, 10, ...list]
// [11, 10, 9, 8, 7, 6, 5]
The spread operator replaces the need to call concat
. The spread operator splits each of the values in list
into individual parameters in the array literal constructor. As a result, list
is added onto [11, 10]
.
Destructured parameters
We've already learned all about destructuring, but in that article we didn't talk about destructuring function parameters because it uses some of the parameter handling features we've just learned.
Let's pretend we needed to implement an ajax
method that takes the URL endpoint as well as a bucket of configuration options. In ES5, this would look something like:
// ES5
function ajax(url, options) {
var method = options.method,
delay = options.delay,
callback = options.callback
console.log(url, method, delay)
setTimeout(function () {
callback('DONE!')
}, delay)
}
ajax('http://api.eventbrite.com/get', {
delay: 2000,
method: 'POST',
callback: function (message) {
console.log(message)
},
})
As you can see, we are trying to get values out of the options
object to use in our ajax
method. In just looking at the method, it's not immediately clear what properties can be in options
. Documentation would be needed. The reason the options
object is used instead of individual parameters is to not explode the arity of the function and because some of the properties of options
could be optional. It's also how named parameters are accomplished in ES5.
In ES6, there is still no official approach to named parameters, but we can get a bit closer using object destructuring of function parameters:
// ES6
function ajax(url, { method, delay, callback }) {
// `method`, `delay` & `callback` are
// destructured variables
console.log(url, method, delay)
setTimeout(() => callback('DONE!'), delay)
}
ajax('http://api.eventbrite.com/get', {
delay: 2000,
method: 'POST',
callback: function (message) {
console.log(message)
},
})
No more need to declare and assign the individual method
, delay
and callback
variables.
What if we want to support options
being optional? Well in ES5 the implementation would have to start with:
// ES5
function ajax(url, options) {
// default `options` to empty object
// so var declarations don't throw error
// if `options` is `undefined`
options = options || {}
// var declarations and
// rest of the function...
}
Well guess what? We can use default parameters to simply default options
to the empty object in ES6:
// ES6
function ajax(url, { method, delay, callback } = {}) {
// default {} is used to allow
// object to be unspecified w/o
// causing an error
// rest of the function
}
See what we did there? We combined default values with parameter destructuring. If options
is left unspecified or undefined
, it'll trigger the default value of {}
which then gets destructured into method
, delay
and callback
. If we didn't provide a default value and didn't specify options
in the call to ajax
, we would get an error because we're trying to destructure undefined
. It's good practice to specify a default value for object destructured parameters.
Ok, what if we wanted to have default values for method
and delay
? They are within options
. In ES5, we'd do something like:
// ES5
function ajax(url, options) {
options = options || {}
// default the values of `method` and
// `delay`
var method = options.method || 'GET',
delay = options.delay || 1000,
callback = options.callback
// rest of the function...
}
In ES6, we can use default values within our object destructure pattern to accomplish the property defaulting:
// ES6
function ajax(url, { method = 'GET', delay = 1000, callback } = {}) {
// default values w/in destructure pattern
// rest of the function
}
Nested default values! Cool, huh? That's the power of ES6.
JavaScript engine support
Good news! According to the ECMAScript 6 Compatibility table, all the major JavaScript engines (browsers, servers & transpilers) support some or all of the parameter handling features.
- Traceur (basically all)
- Babel (basically all)
- Edge (rest parameters & spread operator only)
- Firefox (all except default handling w/ destructured parameters)
- Chrome/Opera (rest parameters & spread operator only)
- Safari (spread operator & destructured parameters only)
- Node 4 (rest parameters & spread operator only)
If it wasn't apparent from the previous articles that transpilation is the only viable solution for production-ready code, it should be obvious now. There is just too much variance in native support among the JS engines, so we can only rely on the transpilers.
Additional Resources
As always, you can check out the Learning ES6 examples page for the Learning ES6 Github repo where you will find all of the code used in this article running natively in the browser for those that support all of the parameter handling features. Unfortunately, there isn't a browser that supports all of the features, so you will need to use the examples running through Babel and Traceur transpilation.
You can also practice everything you've learned on ES6 Katas. It uses a TDD (test-driven development) approach for you to implement ES6 features such that all of the tests pass. I highly recommend it!
Finally, if this information wasn't enough, there is even more you can read concerning parameter handling in ES6:
- Parameter Handling in Exploring ES6 by Axel Rauschmayer
- Functions in Understanding ECMAScript 6 by Nicholas C. Zakas
- ES6 Spread and Butter in Depth in ES6 in Depth by Nicolas Bevacqua
Coming up next...
Phew! The parameter handling features are pretty simple, but there's just so much you can do with them, that it takes some time to explain. Up next we'll take a look at enhancements to object literals as the Learning ES6 series rolls on. Until then...