Implied Scopes

Scopes are sometimes created in non-obvious places. In practice, these implied scopes don’t often impact your program behavior, but it’s still useful to know they’re happening. Keep an eye out for the following surprising scopes:

  • Parameter scope
  • Function name scope

Parameter Scope

The conversation metaphor in Chapter 2 implies that function parameters are basically the same as locally declared variables in the function scope. But that’s not always true.

Consider:

  1. // outer/global scope: RED(1)
  2. function getStudentName(studentID) {
  3. // function scope: BLUE(2)
  4. // ..
  5. }

Here, studentID is a considered a “simple” parameter, so it does behave as a member of the BLUE(2) function scope. But if we change it to be a non-simple parameter, that’s no longer technically the case. Parameter forms considered non-simple include parameters with default values, rest parameters (using ...), and destructured parameters.

Consider:

  1. // outer/global scope: RED(1)
  2. function getStudentName(/*BLUE(2)*/ studentID = 0) {
  3. // function scope: GREEN(3)
  4. // ..
  5. }

Here, the parameter list essentially becomes its own scope, and the function’s scope is then nested inside that scope.

Why? What difference does it make? The non-simple parameter forms introduce various corner cases, so the parameter list becomes its own scope to more effectively deal with them.

Consider:

  1. function getStudentName(studentID = maxID, maxID) {
  2. // ..
  3. }

Assuming left-to-right operations, the default = maxID for the studentID parameter requires a maxID to already exist (and to have been initialized). This code produces a TDZ error (Chapter 5). The reason is that maxID is declared in the parameter scope, but it’s not yet been initialized because of the order of parameters. If the parameter order is flipped, no TDZ error occurs:

  1. function getStudentName(maxID,studentID = maxID) {
  2. // ..
  3. }

The complication gets even more in the weeds if we introduce a function expression into the default parameter position, which then can create its own closure (Chapter 7) over parameters in this implied parameter scope:

  1. function whatsTheDealHere(id,defaultID = () => id) {
  2. id = 5;
  3. console.log( defaultID() );
  4. }
  5. whatsTheDealHere(3);
  6. // 5

That snippet probably makes sense, because the defaultID() arrow function closes over the id parameter/variable, which we then re-assign to 5. But now let’s introduce a shadowing definition of id in the function scope:

  1. function whatsTheDealHere(id,defaultID = () => id) {
  2. var id = 5;
  3. console.log( defaultID() );
  4. }
  5. whatsTheDealHere(3);
  6. // 3

Uh oh! The var id = 5 is shadowing the id parameter, but the closure of the defaultID() function is over the parameter, not the shadowing variable in the function body. This proves there’s a scope bubble around the parameter list.

But it gets even crazier than that!

  1. function whatsTheDealHere(id,defaultID = () => id) {
  2. var id;
  3. console.log(`local variable 'id': ${ id }`);
  4. console.log(
  5. `parameter 'id' (closure): ${ defaultID() }`
  6. );
  7. console.log("reassigning 'id' to 5");
  8. id = 5;
  9. console.log(`local variable 'id': ${ id }`);
  10. console.log(
  11. `parameter 'id' (closure): ${ defaultID() }`
  12. );
  13. }
  14. whatsTheDealHere(3);
  15. // local variable 'id': 3 <--- Huh!? Weird!
  16. // parameter 'id' (closure): 3
  17. // reassigning 'id' to 5
  18. // local variable 'id': 5
  19. // parameter 'id' (closure): 3

The strange bit here is the first console message. At that moment, the shadowing id local variable has just been var id declared, which Chapter 5 asserts is typically auto-initialized to undefined at the top of its scope. Why doesn’t it print undefined?

In this specific corner case (for legacy compat reasons), JS doesn’t auto-initialize id to undefined, but rather to the value of the id parameter (3)!

Though the two ids look at that moment like they’re one variable, they’re actually still separate (and in separate scopes). The id = 5 assignment makes the divergence observable, where the id parameter stays 3 and the local variable becomes 5.

My advice to avoid getting bitten by these weird nuances:

  • Never shadow parameters with local variables

  • Avoid using a default parameter function that closes over any of the parameters

At least now you’re aware and can be careful about the fact that the parameter list is its own scope if any of the parameters are non-simple.

Function Name Scope

In the “Function Name Scope” section in Chapter 3, I asserted that the name of a function expression is added to the function’s own scope. Recall:

  1. var askQuestion = function ofTheTeacher(){
  2. // ..
  3. };

It’s true that ofTheTeacher is not added to the enclosing scope (where askQuestion is declared), but it’s also not just added to the scope of the function, the way you’re likely assuming. It’s another strange corner case of implied scope.

The name identifier of a function expression is in its own implied scope, nested between the outer enclosing scope and the main inner function scope.

If ofTheTeacher was in the function’s scope, we’d expect an error here:

  1. var askQuestion = function ofTheTeacher(){
  2. // why is this not a duplicate declaration error?
  3. let ofTheTeacher = "Confused, yet?";
  4. };

The let declaration form does not allow re-declaration (see Chapter 5). But this is perfectly legal shadowing, not re-declaration, because the two ofTheTeacher identifiers are in separate scopes.

You’ll rarely run into any case where the scope of a function’s name identifier matters. But again, it’s good to know how these mechanisms actually work. To avoid being bitten, never shadow function name identifiers.