Skip to content

Lexical Scope: JavaScript's Variable Lookup Rules

In a large library, books are categorized and placed in different areas and shelves. When you need to find a book, you first search the shelves closest to you, and if you can't find it, you expand your search to adjacent areas, eventually needing to search the entire library. JavaScript's lexical scope is like this set of "book search rules" that determines how code looks up variables at runtime.

Lexical scope is also called static scope because it's determined when the code is written and doesn't change at runtime. Understanding lexical scope is key to mastering JavaScript closures, modularization, and other advanced features.

What is Lexical Scope?

Lexical scope means that scope is determined by where functions are declared in the code, not by where functions are called. In other words, variable accessibility is determined when you write the code—the JavaScript engine can know which scope each variable belongs to when parsing the code.

Lexical Scope vs Dynamic Scope

JavaScript uses lexical scope (as do most programming languages), while some languages (like Bash, certain Perl modes) use dynamic scope. The difference lies in the basis of variable lookup:

Lexical Scope: Variables are looked up based on their position in the code

javascript
let name = "Global";

function outer() {
  let name = "Outer";

  function inner() {
    console.log(name); // When looking up variables, see where inner is defined
  }

  return inner;
}

const fn = outer();
fn(); // "Outer" - Lexical scope looks up based on definition location

In this example, the inner function is defined inside the outer function, so when inner accesses the name variable, it starts looking from where it's defined (the outer function), regardless of where inner is called.

Dynamic Scope (JavaScript doesn't support this, shown for comparison only): If JavaScript used dynamic scope, variable lookup would be based on function call location:

javascript
// Assume JavaScript uses dynamic scope (it doesn't)
let name = "Global";

function showName() {
  console.log(name);
}

function context1() {
  let name = "Context 1";
  showName(); // Dynamic scope would output "Context 1"
}

function context2() {
  let name = "Context 2";
  showName(); // Dynamic scope would output "Context 2"
}

// But JavaScript actually uses lexical scope
context1(); // "Global" - Because showName is defined globally, it looks up the global name
context2(); // "Global"

Fortunately, JavaScript uses lexical scope, which makes code behavior more predictable and easier to understand and maintain.

How Lexical Scope Works

The core of lexical scope is the scope chain. When code needs to access a variable, the JavaScript engine follows the scope chain layer by layer, looking up until it finds the variable or reaches the global scope.

Formation of the Scope Chain

When each function is created, it saves an internal property [[Scope]] that contains the scope chain where the function was defined. When the function executes, it creates a new execution context and adds its own scope to the front of the scope chain.

javascript
let global = "Global Variable";

function outer() {
  let outerVar = "Outer Variable";

  function middle() {
    let middleVar = "Middle Variable";

    function inner() {
      let innerVar = "Inner Variable";

      // Scope chain: inner → middle → outer → global
      console.log(innerVar); // Found in inner scope
      console.log(middleVar); // Found in middle scope
      console.log(outerVar); // Found in outer scope
      console.log(global); // Found in global scope
    }

    inner();
  }

  middle();
}

outer();

In this nested structure, when the inner function executes, its scope chain is:

  1. inner's local scope
  2. middle's scope
  3. outer's scope
  4. Global scope

When looking up variables, the engine follows this order sequentially until it finds a match.

Variable Shadowing

When an inner scope defines a variable with the same name as an outer scope variable, the inner variable "shadows" the outer variable:

javascript
let value = "outer";

function test() {
  let value = "inner"; // Shadows the outer value
  console.log(value); // "inner"

  function nested() {
    let value = "nested"; // Shadows the previous level's value
    console.log(value); // "nested"
  }

  nested();
  console.log(value); // "inner" - Still test's value
}

test();
console.log(value); // "outer" - Global value is not modified

Variable shadowing is like placing a book with the same name in front of another on a shelf—you see the first one and can't see the one behind it. Only by moving the front book can you see the one behind.

Accessing Outer Variables Without Shadowing

If you want to access outer variables in an inner scope, simply don't redeclare them:

javascript
let counter = 0;

function increment() {
  counter++; // Accesses and modifies the outer counter
  console.log(counter);
}

increment(); // 1
increment(); // 2
increment(); // 3
console.log(counter); // 3 - Outer variable is modified

Here, let, const, or var are not used to redeclare counter, so the increment function operates on the outer variable.

Lexical Scope and Closures

Lexical scope is the foundation of closures. A closure refers to a function's ability to remember and access its lexical scope, even when the function is executed outside its lexical scope.

Closure Formation

javascript
function createGreeting(greeting) {
  // greeting is a parameter of createGreeting

  return function (name) {
    // This inner function can access the outer greeting parameter
    console.log(greeting + ", " + name + "!");
  };
}

const sayHello = createGreeting("Hello");
const sayHi = createGreeting("Hi");

sayHello("Alice"); // "Hello, Alice!"
sayHi("Bob"); // "Hi, Bob!"

In this example, although the createGreeting function has finished executing, the returned inner function can still access the greeting parameter. This is because the inner function captures its lexical scope when defined, including the greeting variable.

Each call to createGreeting creates a new scope and new greeting variable, so sayHello and sayHi each remember their respective greeting values.

Practical Closure Example: Data Privacy

Using lexical scope and closures, we can create truly private variables:

javascript
function createBankAccount(initialBalance) {
  let balance = initialBalance; // Private variable

  return {
    deposit(amount) {
      if (amount > 0) {
        balance += amount;
        return balance;
      }
    },
    withdraw(amount) {
      if (amount > 0 && amount <= balance) {
        balance -= amount;
        return balance;
      } else {
        console.log("Insufficient funds or invalid amount");
      }
    },
    getBalance() {
      return balance;
    },
  };
}

const myAccount = createBankAccount(1000);
console.log(myAccount.getBalance()); // 1000
myAccount.deposit(500); // 1500
myAccount.withdraw(300); // 1200
console.log(myAccount.balance); // undefined - Cannot directly access private variables

External code cannot directly access or modify the balance variable and can only operate it through provided methods. This pattern is very useful in environments without class private fields.

Practical Applications of Lexical Scope

1. Module Pattern

Lexical scope is the foundation of JavaScript's module pattern:

javascript
const UserModule = (function () {
  // Private variables and functions
  let users = [];
  let currentId = 0;

  function generateId() {
    return ++currentId;
  }

  // Public API
  return {
    addUser(name, email) {
      const user = {
        id: generateId(),
        name: name,
        email: email,
      };
      users.push(user);
      return user;
    },

    getUser(id) {
      return users.find((user) => user.id === id);
    },

    getAllUsers() {
      // Return a copy to prevent external modification
      return [...users];
    },

    removeUser(id) {
      const index = users.findIndex((user) => user.id === id);
      if (index !== -1) {
        users.splice(index, 1);
        return true;
      }
      return false;
    },
  };
})();

// Use the module
UserModule.addUser("Alice", "[email protected]");
UserModule.addUser("Bob", "[email protected]");
console.log(UserModule.getAllUsers());
// [
//   { id: 1, name: "Alice", email: "[email protected]" },
//   { id: 2, name: "Bob", email: "[email protected]" }
// ]

console.log(UserModule.users); // undefined - Private variables cannot be accessed
console.log(UserModule.currentId); // undefined

This module uses an Immediately Invoked Function Expression (IIFE) to create a private scope, and external code can only access the public methods in the returned object.

2. Callback Functions and Event Handling

Lexical scope allows callback functions to access the context where they were defined:

javascript
function setupButtonHandlers() {
  const buttons = document.querySelectorAll(".action-button");

  buttons.forEach(function (button, index) {
    // Each callback function can access its own index variable
    button.addEventListener("click", function () {
      console.log("Button " + index + " clicked");
      console.log("Button text: " + button.textContent);
    });
  });
}

Each event handler function remembers its corresponding button and index, even when they are called later.

3. Function Factories

Using lexical scope, we can create customized functions:

javascript
function createMultiplier(factor) {
  return function (number) {
    return number * factor;
  };
}

const double = createMultiplier(2);
const triple = createMultiplier(3);
const quadruple = createMultiplier(4);

console.log(double(5)); // 10
console.log(triple(5)); // 15
console.log(quadruple(5)); // 20

Each returned function remembers its own factor value, forming specialized multiplication functions.

4. Currying

Lexical scope makes function currying possible:

javascript
function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    } else {
      return function (...nextArgs) {
        return curried.apply(this, args.concat(nextArgs));
      };
    }
  };
}

function add(a, b, c) {
  return a + b + c;
}

const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6

Each intermediate function remembers the previously passed parameters, which relies entirely on lexical scope.

Common Problems and Misconceptions

Misconception 1: Functions can access variables at call location

javascript
let message = "Global message";

function showMessage() {
  console.log(message);
}

function wrapper() {
  let message = "Wrapper message";
  showMessage(); // What will this output?
}

wrapper(); // "Global message" - Not "Wrapper message"

showMessage accesses the message from the scope where it's defined (global), not the message from the scope where it's called (wrapper).

Misconception 2: All closures in loops share the same variable

This is true with var, but can be avoided with let:

javascript
// Problem code
function createFunctions() {
  var funcs = [];
  for (var i = 0; i < 3; i++) {
    funcs.push(function () {
      console.log(i);
    });
  }
  return funcs;
}

const functions = createFunctions();
functions[0](); // 3
functions[1](); // 3
functions[2](); // 3

// Solution 1: Use let
function createFunctionsFixed() {
  var funcs = [];
  for (let i = 0; i < 3; i++) {
    // Use let
    funcs.push(function () {
      console.log(i);
    });
  }
  return funcs;
}

const fixedFunctions = createFunctionsFixed();
fixedFunctions[0](); // 0
fixedFunctions[1](); // 1
fixedFunctions[2](); // 2

// Solution 2: Use IIFE to create new scope
function createFunctionsIIFE() {
  var funcs = [];
  for (var i = 0; i < 3; i++) {
    funcs.push(
      (function (index) {
        return function () {
          console.log(index);
        };
      })(i)
    );
  }
  return funcs;
}

const iifeFunctions = createFunctionsIIFE();
iifeFunctions[0](); // 0
iifeFunctions[1](); // 1
iifeFunctions[2](); // 2

Misconception 3: Closures cause memory leaks

Closures themselves don't cause memory leaks, but improper usage can:

javascript
// Potential memory problem
function createHeavyObject() {
  const hugeData = new Array(1000000).fill("data");

  return {
    // This function maintains a reference to the entire hugeData
    getFirstItem() {
      return hugeData[0];
    },
  };
}

const obj = createHeavyObject(); // hugeData will remain in memory

The solution is to only keep the data you need:

javascript
function createLightObject() {
  const hugeData = new Array(1000000).fill("data");
  const firstItem = hugeData[0]; // Only keep the data you need

  return {
    getFirstItem() {
      return firstItem; // Don't reference the entire array
    },
  };
}

const obj = createLightObject(); // Better: hugeData can be garbage collected

Advantages of Lexical Scope

1. Predictability

Because scope is determined when writing code, we can understand variable sources by reading the code, without needing to trace the runtime call chain.

javascript
let x = 10;

function outer() {
  let x = 20;

  function inner() {
    console.log(x); // Can see at a glance that this will output 20
  }

  inner();
}

outer();

2. Performance Optimization

JavaScript engines can determine variable scope at compile time and perform optimizations, rather than needing dynamic lookups at runtime.

3. Encapsulation

Lexical scope provides a natural encapsulation mechanism, allowing us to create private variables and functions and control external access.

Best Practices

1. Use Lexical Scope to Create Private State

javascript
function createTimer() {
  let seconds = 0;
  let intervalId = null;

  return {
    start() {
      if (intervalId === null) {
        intervalId = setInterval(() => {
          seconds++;
          console.log("Elapsed: " + seconds + "s");
        }, 1000);
      }
    },

    stop() {
      if (intervalId !== null) {
        clearInterval(intervalId);
        intervalId = null;
      }
    },

    reset() {
      seconds = 0;
      console.log("Timer reset");
    },

    getTime() {
      return seconds;
    },
  };
}

const timer = createTimer();
timer.start();
// A few seconds later...
timer.stop();
console.log("Total time: " + timer.getTime() + "s");

2. Avoid Accidental Global Variables

javascript
// Bad -容易创建全局变量
function badExample() {
  result = 10; // Forgot to declare, created global variable
}

badExample();
console.log(result); // 10 - Accidental global variable

// Good - Always declare
function goodExample() {
  const result = 10; // Clearly declared as local variable
  return result;
}

goodExample();
console.log(typeof result); // "undefined"

3. Use IIFE to Create Temporary Scope

When you need to execute some initialization code without polluting the external scope:

javascript
// Use IIFE to create temporary scope
(function () {
  const tempData = fetchData();
  const processed = processData(tempData);
  const config = generateConfig(processed);

  initializeApp(config);

  // tempData, processed, config are destroyed here after execution
})();

Summary

Lexical scope is the basic rule for variable lookup in JavaScript. Its core characteristics are:

  • Scope is determined by code writing position, not call position
  • Variable lookup proceeds from inside to outside along the scope chain
  • Inner scopes can access outer variables, but outer scopes cannot access inner ones
  • Variables with the same name create shadowing effects

Lexical scope makes JavaScript behavior more predictable and is the foundation for important features like closures and modularization. By properly utilizing lexical scope, we can:

  • Create truly private variables and methods
  • Implement data encapsulation and information hiding
  • Write safer, more maintainable code

In the following chapters, we'll explore specific scope types like block scope and function scope in detail, and how to make full use of these features in actual development. Understanding lexical scope is the key first step to mastering these advanced topics.