in ECMAScript

Note 6. ES6: Default values of parameters

Read this article in Chinese, Russian.

In this small note we’ll cover another ES6 feature, function parameters with default values. As we will see there are some subtle cases there.

Previously default parameter values were handled manually in several alternative ways:

function log(message, level) {
  level = level || 'warning';
  console.log(level, ': ', message);
}

log('low memory'); // warning: low memory
log('out of memory', 'error'); // error: out of memory

To avoid possible “falsey values”, often one can see typeof check:

if (typeof level == 'undefined') {
  level = 'warning';
}

Sometimes, one could check also arguments.length:

if (arguments.length == 1) {
  level = 'warning';
}

All these approaches worked well, however, all of them are too manual, and less abstract. ES6 standardized a syntactic construct to define a default value for a parameter directly in the head of a function.

Default parameter values are present in many languages, so the basic form should probably be familiar to most of the developers:

function log(message, level = 'warning') {
  console.log(level, ': ', message);
}

log('low memory'); // warning: low memory
log('out of memory', 'error'); // error: out of memory

Pretty casual default parameters usage, and yet convenient. Let’s dive into implementation details to clarify possible confusions that may arise with default parameters.

Below are several specific to ES6 implementation details of function default parameter values.

In contrast with some other languages (e.g. Python), that may calculate default parameters once, at definition time, ECMAScript evaluates default parameter values at execution time — on every single function call. This design choice was made to avoid confusions with complex objects used as default values. Consider the following Python example:

def foo(x = []):
  x.append(1)
  return x

# We can see that defaults are created once, when
# the function is defined, and are stored as
# just a property of the function
print(foo.__defaults__) # ([],)

foo() # [1]
foo() # [1, 1]
foo() # [1, 1, 1]

# The reason for this as we said:
print(foo.__defaults__) # ([1, 1, 1],)

To avoid such behavior Python developers used to define the default value as None, and make an explicit check for this value:

def foo(x = None):
  if x is None:
    x = []
  x.append(1)
  print(x)

print(foo.__defaults__) # (None,)

foo() # [1]
foo() # [1]
foo() # [1]

print(foo.__defaults__) # ([None],)

However, this is the same manual inconvenient handling of the actual default value, and the initial case is just confusing. That’s said, to avoid this, ECMAScript defaults are evaluated on every function execution:

function foo(x = []) {
  x.push(1);
  console.log(x);
}

foo(); // [1]
foo(); // [1]
foo(); // [1]

All good and intuitive. Now let’s see when ES semantics may also confuse if not to know how it works.

Consider the following example:

var x = 1;

function foo(x, y = x) {
  console.log(y);
}

foo(2); // 2, not 1!

The example above outputs 2 for y, not 1, as visually may look like. The reason for this is that x from the parameters is not the same as the global x. And since evaluation of the defaults happens at call time, then when assignment, = x, happens, the x is already resolved in the inner scope, and refers to the x parameter itself. That is parameter x shadowed global variable with the same name, and every access to x from the default values refers to the parameter.

ES6 mentions so called TDZ (stands for Temporal Dead Zone) — this is the region of a program, where a variable or a parameter cannot be accessed until it’s initialized (i.e. received a value).

Regarding parameters, a parameter cannot have default value of itself:

var x = 1;

function foo(x = x) { // throws!
  ...
}

The assignment = x as we mentioned above resolves x in the parameters scope, that shadowed the global x. However, the parameter x is under the TDZ, and cannot be accessed until it’s initialized. Obviously, it cannot be initialized to itself.

Notice, that previous example from above with y is valid, since x is already initialized (to implicit default value undefined). Let’s show it again:

function foo(x, y = x) { // OK
  ...
}

The reason why it’s allowed is that parameters in ECMAScript are initialized from left to right order, and we have x already available for usage.

We mentioned that parameters are related already with the “inner scope”, which from ES5 we could assume it’s the scope of the function body. However, it’s a bit more complicated: it may be a scope of a function, or, an intermediate scope, created specially to store parameters bindings. Let’s consider them.

In fact, in case if some (at least one) of the parameters have default values, ES6 defines an intermediate scope to store the parameters, and this scope is not shared with the scope of the function body. This is a major difference from ES5 in this respect. Let’s demonstrate it on the example:

var x = 1;

function foo(x, y = function() { x = 2; }) {
  var x = 3;
  y(); // is `x` shared?
  console.log(x); // no, still 3, not 2
}

foo();

// and the outer `x` is also left untouched
console.log(x); // 1

In this case, we have three scopes: the global environment, the parameters environment, and the environment of the function:

  :  {x: 3} // inner
  -> {x: undefined, y: function() { x = 2; }} // params
  -> {x: 1} // global

Now we see that when function y is executed, it resolves x in the nearest environment (i.e. in the same environment), and doesn’t even see the scope of the function.

If we’re about to compile ES6 code to ES5, and see how this intermediate scope looks like, we would get something like this:

  // ES6
  function foo(x, y = function() { x = 2; }) {
    var x = 3;
    y(); // is `x` shared?
    console.log(x); // no, still 3, not 2
  }

  // Compiled to ES5
  function foo(x, y) {
    // Setup defaults.
    if (typeof y == 'undefined') {
      y = function() { x = 2; }; // now clearly see that it updates `x` from params
    }

    return function() {
      var x = 3; // now clearly see that this `x` is from inner scope
      y();
      console.log(x);
    }.apply(this, arguments);
  }

However, what is the exact purpose of this params scope? Why can’t we still do ES5-way and share params with the function body? The reason is: the variables in the body with the same name should not affect captured in closures bindings with the same name.

Let’s show on the example:

var x = 1;

function foo(y = function() { return x; }) { // capture `x`
  var x = 2;
  return y();
}

foo(); // correctly 1, not 2

If we create the function y in the scope of the body, it would capture inner x, that is, 2. However, it’s clearly observable, that it should capture the outer x, i.e. 1 (unless it’s shadowed by the parameter with the same name).

At the same time we cannot create the function in the outer scope, since this would mean we won’t be able to access the parameters from such function, and we should be able to do this:

var x = 1;

function foo(y, z = function() { return x + y; }) { // can see `x` and `y`
  var x = 3;
  return z();
}

foo(1); // 2, not 4

The described above semantics completely different than what we have in manual implementation of defaults:

var x = 1;

function foo(x, y) {
  if (typeof y == 'undefined') {
    y = function() { x = 2; };
  }
  var x = 3;
  y(); // is `x` shared?
  console.log(x); // yes! 2
}

foo();

// and the outer `x` is again untouched
console.log(x); // 1

Now that’s an interesting fact: if a function doesn’t have defaults, it doesn’t create this intermediate scope, and share the parameters binding in the environment of a function, i.e. works in the ES5-mode.

Why these complications? Why not always create the parameters scope? Is it just about optimizations? Not really. The reason for this is exactly legacy backward compatibilities with ES5: the code from above with manual implementation of defaults should update x from the function body (which is the parameter itself, and is in the same scope).

Notice also, that those redeclarations are allowed only for vars and functions. It’s not possible to redeclare parameter using let or const:

function foo(x = 5) {
  let x = 1; // error
  const x = 2; // error
}

Another interesting thing to note, is that the fact whether a default value should be applied, is based on checking the initial value (that is assigned on entering the context) of the parameter with the value of undefined. Let’s demonstrate it:

  function foo(x, y = 2) {
    console.log(x, y);
  }

  foo(); // undefined, 2
  foo(1); // 1, 2

  foo(undefined, undefined); // undefined, 2
  foo(1, undefined); // 1, 2

Usually in programming languages parameters with default values go after the required parameters, however, the fact from above allows us to have the following construct in JavaScript:

  function foo(x = 2, y) {
    console.log(x, y);
  }

  foo(1); // 1, undefined
  foo(undefined, 1); // 2, 1

Another place where default values participate is the defaults of destructured components. The topic of destructuring assignment is not covered in this article, but let’s show some small examples. The handling of defaults in case of using destructuring in the function parameters is the same as with simple defaults described above: i.e create two scopes if needed.

function foo({x, y = 5}) {
  console.log(x, y);
}

foo({}); // undefined, 5
foo({x: 1}); // 1, 5
foo({x: 1, y: 2}); // 1, 2

Although, defaults of destructuring are more generic, and can be used not only in functions:

var {x, y = 5} = {x: 1};
console.log(x, y); // 1, 5

Hope this brief note helped to explain the details of default values in ES6. Notice, that at the day of writing (Aug 21, 2014), none of the implementations implement the defaults correctly (all of them just create one scope, which is shared with the function body), since this “second scope” was just recently added to the spec draft. The default values are definitely a useful feature, that will make our code more elegant and concise.

Written by: Dmitry Soshnikov
Published on: Aug 21, 2014

Write a Comment

Comment

  1. Thanks Dmitry, very informative article! Look forward to your further ES6 explanations.

  2. hi, Dmitry, this is the Chinese translated version of this article. Link address:
    http://bosn.me/articles/es6-default-param/

    Hope more people reading this fantastic article.

    Thank you for your great work, your article really help me a lot understand ES6 details. Looking forward to your more articles!

  3. @Alex Potorenko, glad it’s useful, thanks!

    @Bosn, thanks, great job on the translation. Will add a link to it later.

  4. I never use None as placeholder default arg in Python. Why? Because it doesn’t make sense. Functions with None as defaults are harder to read and it doesn’t give you any benefit in reality. Same thing here, in JS. As soon as you begin to mutate value – you starting to lean on the fragile fact that value comes from default, not from external call (which you don’t want to mutate generally). The both Python and JS are equally bad here. Python approach is more devastating yet bug are easier to catch (cause you deal with bugs of such nature more frequently). JS, as always, bet on tiny, hidden bugs 😀

  5. Be careful Babel transpiles Conditional intermediate scope for parameters incorrectly

    "use strict";
    
    // ES5
    function foo(x) {
      var y = arguments.length <= 1 || arguments[1] === undefined ? function () {
        x = 2;
      } : arguments[1];
    
      var x = 3;
      y(); // is `x` shared?
      console.log(x); 
    }
    
  6. Thanks for the detailed expectation! But I am having trouble finding the “parameters environment” part in the specification. Dmitry can you name a few keywords? That would be really helpful. Thanks again!

  7. @Jack, thanks, glad it’s useful. For the separate scope for parameters, see step 27.a of the 9.2.12 FunctionDeclarationInstantiation:

    NOTE A separate Environment Record is needed to ensure that closures created by expressions in the formal parameter list do not have visibility of declarations in the function body.