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.
Manual defaults from ES5 and lower
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.
ES6 defaults: basic example
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.
Implementation details
Below are several specific to ES6 implementation details of function default parameter values.
Fresh evaluation at execution time
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.
Shadowing of the outer scope
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.
TDZ (Temporal Dead Zone) for parameters
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.
Conditional intermediate scope for parameters
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.
Transpiling to ES5
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); }
The reason for the params scope
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
When the params scope is not created
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 var
s 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 }
The check for undefined
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
Default values of destructured components
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
Conclusion
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
Thanks Dmitry, very informative article! Look forward to your further ES6 explanations.
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!
@Alex Potorenko, glad it’s useful, thanks!
@Bosn, thanks, great job on the translation. Will add a link to it later.
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 😀
Be careful Babel transpiles Conditional intermediate scope for parameters incorrectly
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!
@Jack, thanks, glad it’s useful. For the separate scope for parameters, see step
27.a
of the 9.2.12 FunctionDeclarationInstantiation:Hi,Dmitry,thanks for your excellent article. The original Chinese translated version link can’ be accessed now.Could I translate this article to Chinese again?
@Chor, thanks, fixed. Yes, please feel free to translate it, and send me the link.
@Dmitry Soshnikov Feel doubt for the subtitle “When the params scope is not created”.Does it mean that “the time when the params scope is not created” or mean that “when is the params scope not created “?
@Dmitry Soshnikov Hi,here is the link https://juejin.im/post/5cd0eab95188251b984d8abe
@Chor
This means when the scope for parameters itself is not created.
Thanks, I added the link!
Hi, thanks for your great article. But I still remain one confusion.
If the parameter list is a different scope than the function body, why using let to declare a variable with the same name of one parameter in the function body would get a SyntaxError?
@Liam, thanks, and great question. This just per spec, the
let
bindings inside the main function body are checked against the the parameter names (forbidding the duplicates). Though to confirm that a separate scope is created, you can replace thex
in the body tovar
, and thef
closure should still print2
, not5
.Hi,Dmitry Soshnikov.Still feel doubt for something.
1.What is “This just per spec, the let bindings inside the main function body are checked against the the parameter names (forbidding the duplicates)” in your former reply? Does it mean once duplicates found,let will always get a SyntaxError?
2.What is the relationship of these 3 kinds of scopes?I think is:global scope contains parameter scope and parameter scope contains function scope.And this can explain many examples excepet the snippet code below:
If we found x in the func body,then we just console it.Else,if we didn’t find x in the func body,then we continue to search it in the parameter scope or even the global scope.However,since here the x has been found in the function body,why finally the ouput is 1 instead of undefined?
@Chor, correct,
let
doesn’t allow having duplicates. Parameter names cannot be duplicated as well in strict mode.You can put
debugger
statement to see specific scopes:The relationship of scopes is: Global -> parameter -> function body.
great work:)