Polyglot
Languages Web (HTML / CSS / JS) scope
Web (HTML / CSS / JS) § scope

Scope and modules

JavaScript admits lexical scoping — variables are resolved in the scope where they are declared, not where they are called. The principal scoping rules: var admits function scope (legacy); let and const admit block scope. The conventional contemporary form is ES modules (import/export) — admits module scope (each module has its own namespace; explicit imports for cross-module access). Browsers admit modules via <script type="module">. Closures admit substantial state encapsulation. The combination — block-scoped let/const, module-scoped import/export, the closure mechanism for substantial state encapsulation, the lexical this of arrow functions, the conventional 'use strict' mode — is the substance of JavaScript’s organisational model.

Block scope

let and const admit block-scoped declarations:

{
    let x = 5;
    const y = 10;
    // x and y visible here
}
// x and y NOT visible here

if (condition) {
    let inner = 1;
    // inner visible
}
// inner NOT visible

for (let i = 0; i < 5; i++) {
    // i scoped to each iteration
}
// i NOT visible

The principal blocks:

  • Function bodies.
  • Control-flow blocksif, else, for, while, switch, try/catch/finally.
  • Explicit { } blocks.

var (legacy)

var admits function scope — visible throughout the enclosing function:

function foo() {
    if (condition) {
        var x = 5;
    }
    console.log(x);                                // visible! (var is function-scoped)
}

// Hoisting:
console.log(x);                                    // undefined (hoisted)
var x = 5;

The conventional contemporary discipline avoids var — admit substantial pitfalls.

let and const differences

// const — no reassignment:
const x = 5;
x = 6;                                             // ERROR

const obj = { count: 0 };
obj.count = 1;                                     // OK (mutating contents)
obj = {};                                          // ERROR (reassigning binding)

// let — reassignable:
let y = 5;
y = 6;                                             // OK

// Both — block scoped, no redeclaration:
let z = 5;
let z = 6;                                         // ERROR (redeclaration)

The conventional discipline:

  • Default to const — admit immutable bindings.
  • Use let when reassignment is required.
  • Avoid var.

Temporal Dead Zone

let and const declarations are hoisted to the top of their block but cannot be accessed before the declaration:

console.log(x);                                    // ReferenceError (TDZ)
let x = 5;

// Compare with var:
console.log(y);                                    // undefined (no TDZ)
var y = 5;

The TDZ admits substantial early-error detection.

Function declarations and hoisting

Function declarations are fully hoisted:

greet();                                           // works (hoisted)

function greet() {
    console.log("hello");
}

Function expressions assigned to variables are not hoisted (the variable is, but not the function):

greet();                                           // TypeError (greet is undefined)

var greet = function () {
    console.log("hello");
};

For consistency, modern JavaScript uses arrow functions or const + function expression:

const greet = () => {
    console.log("hello");
};

greet();                                           // works AFTER the declaration

Closures

Functions admit closures over their lexical scope:

function makeCounter() {
    let count = 0;
    return function () {
        count += 1;
        return count;
    };
}

const counter = makeCounter();
counter();                                         // 1
counter();                                         // 2
counter();                                         // 3

Each call to makeCounter produces a new closure with its own count — substantial state encapsulation.

For shared state:

function makePair() {
    let value = 0;
    return {
        increment() { value += 1; return value; },
        get() { return value; }
    };
}

const pair = makePair();
pair.increment();
pair.increment();
console.log(pair.get());                           // 2

ES Modules

The conventional contemporary form for code organisation:

// math.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export const PI = 3.14159;
export default function multiply(a, b) { return a * b; }

// main.js
import multiply, { add, subtract, PI } from "./math.js";
import * as math from "./math.js";

console.log(add(2, 3));                            // 5
console.log(math.PI);                              // 3.14159
console.log(multiply(2, 3));                       // 6

In HTML:

<script type="module" src="main.js"></script>

<!-- Inline: -->
<script type="module">
    import { greet } from "./greet.js";
    greet("World");
</script>

Module features

ES modules admit substantial features:

// Re-export:
export { add, subtract } from "./math.js";
export * from "./math.js";
export { default } from "./math.js";

// Renamed exports:
export { add as addNumbers, subtract as subtractNumbers };

// Default export:
export default function () { ... }                 // anonymous default
export default class Foo { }
export default { name: "config" };

// Renamed imports:
import { add as plus } from "./math.js";

// Dynamic import:
const module = await import("./math.js");

Module characteristics

  • Always strict mode'use strict' implicit.
  • Top-level await admitted — admit substantial async initialisation.
  • Each module is its own scope — no shared globals.
  • this is undefined at module top level (vs window in scripts).
  • Imports are live bindings — admit substantial late binding.
  • Modules execute once — cached on first import.

Top-level await

// In an ES module:
const data = await fetch("/api/data").then(r => r.json());
console.log(data);

// Use in conditional imports:
if (someCondition) {
    const { feature } = await import("./optional-feature.js");
    feature();
}

The mechanism admits substantial asynchronous module initialisation.

CommonJS (Node.js legacy)

// math.js (CommonJS)
function add(a, b) { return a + b; }
module.exports = { add };
module.exports.subtract = (a, b) => a - b;

// main.js
const { add, subtract } = require("./math.js");
const math = require("./math.js");

The conventional contemporary discipline uses ES modules; CommonJS admits substantial legacy and is conventional in some Node.js codebases.

Lexical this

Arrow functions admit lexical this — they inherit this from the enclosing scope:

class Counter {
    constructor() {
        this.count = 0;
    }

    // Method (this depends on call site):
    increment() {
        this.count += 1;
    }

    // Arrow (lexical this):
    incrementArrow = () => {
        this.count += 1;                           // always the instance
    };
}

const c = new Counter();
const inc = c.increment;
inc();                                             // TypeError: Cannot read 'count' of undefined

const incA = c.incrementArrow;
incA();                                            // works (lexical this)

The mechanism admits substantial event-handler patterns:

class Component {
    constructor() {
        this.count = 0;
        // Arrow admits binding:
        button.addEventListener("click", () => this.count++);

        // Without arrow, this is the button:
        button.addEventListener("click", function () {
            console.log(this);                      // the button, not the Component
        });
    }
}

globalThis

The standard global reference (works in browser, Node.js, workers):

globalThis.someGlobal = "value";

// Older alternatives:
window.someGlobal;                                 // browser
self.someGlobal;                                   // worker
global.someGlobal;                                 // Node.js

The conventional discipline avoids globals — admit substantial namespace pollution and substantial coupling.

IIFE (Immediately Invoked Function Expression)

The pre-modules pattern for namespacing:

(function () {
    const private = "hidden";
    // ...
})();

// Or with arrow (not technically IIFE but equivalent):
(() => {
    const private = "hidden";
})();

The mechanism admits module-like scoping in legacy code; the conventional contemporary discipline uses ES modules.

Common patterns

Module pattern

// counter.js
let count = 0;

export function increment() {
    count += 1;
    return count;
}

export function get() {
    return count;
}

export function reset() {
    count = 0;
}
// main.js
import { increment, get } from "./counter.js";
increment();
increment();
console.log(get());                                // 2

Singleton via module

Modules execute once; the conventional pattern:

// db.js
let connection = null;

export function connect() {
    if (!connection) {
        connection = createConnection();
    }
    return connection;
}

Facade

// api.js
import { fetchUser } from "./user-api.js";
import { fetchPosts } from "./posts-api.js";
import { fetchComments } from "./comments-api.js";

export const api = {
    users: { fetch: fetchUser },
    posts: { fetch: fetchPosts },
    comments: { fetch: fetchComments }
};

Lazy module loading

const button = document.getElementById("show-chart");

button.addEventListener("click", async () => {
    const { renderChart } = await import("./chart.js");  // load on demand
    renderChart(data);
});

The mechanism admits substantial code-splitting — the chart module is loaded only when needed.

Re-export

// index.js (barrel file)
export { default as Button } from "./Button.js";
export { default as Card } from "./Card.js";
export { default as Modal } from "./Modal.js";
export * from "./types.js";

Closure for private state

function createUser(name) {
    let private_age = 0;

    return {
        getName() { return name; },
        setAge(age) { private_age = age; },
        getAge() { return private_age; }
    };
}

const u = createUser("Alice");
u.setAge(30);
console.log(u.getAge());                           // 30
// private_age not accessible

Event handler with lexical this

class TabPanel {
    constructor(element) {
        this.element = element;
        this.currentTab = 0;

        // Arrow admits lexical this:
        this.element.addEventListener("click", (e) => {
            if (e.target.matches(".tab")) {
                this.selectTab(e.target.dataset.tabIndex);
            }
        });
    }

    selectTab(index) {
        this.currentTab = parseInt(index);
        this.render();
    }

    render() { ... }
}

Module configuration

// config.js
const config = {
    apiUrl: process.env.API_URL ?? "https://api.example.com",
    timeout: 30000,
    retries: 3
};

export default Object.freeze(config);              // immutable

Dynamic loading by condition

async function loadFeatureModule(featureName) {
    if (!isFeatureEnabled(featureName)) {
        return null;
    }
    return import(`./features/${featureName}.js`);
}

const adminModule = await loadFeatureModule("admin");
if (adminModule) {
    adminModule.init();
}

Hoisting workaround for substantial recursion

// Function declarations admit hoisting:
function isEven(n) {
    return n === 0 ? true : isOdd(n - 1);
}

function isOdd(n) {
    return n === 0 ? false : isEven(n - 1);
}

// With const/arrow, mutual recursion requires restructuring:
const isEven = (n) => n === 0 ? true : isOdd(n - 1);
const isOdd = (n) => n === 0 ? false : isEven(n - 1);
// Works because called only at runtime, not at declaration time

Block-scoped let in for

const buttons = [];
for (let i = 0; i < 3; i++) {
    buttons.push(() => console.log(i));            // each closure has its own i
}

buttons[0]();                                      // 0
buttons[1]();                                      // 1
buttons[2]();                                      // 2

// With var (legacy):
const buttonsVar = [];
for (var j = 0; j < 3; j++) {
    buttonsVar.push(() => console.log(j));         // all share the same j
}
buttonsVar[0]();                                   // 3 (loop ended; j=3)
buttonsVar[1]();                                   // 3
buttonsVar[2]();                                   // 3

The let admits per-iteration binding — substantial improvement over var.

A note on eval and Function

eval("const x = 5; console.log(x)");               // executes string as code
new Function("x", "return x * 2");                 // creates function from string

The conventional discipline avoids eval — admit substantial security risks and substantial performance penalties.

A note on the conventional discipline

The contemporary JavaScript scope/modules advice:

  • Use const by default; let for reassignment; never var.
  • Use ES modules (type="module").
  • Use named exports over default exports — admit substantial refactoring.
  • Use import.meta.url for module-relative paths.
  • Use top-level await for substantial async module initialisation.
  • Use dynamic import() for code-splitting.
  • Use arrow functions for lexical this (handlers, callbacks).
  • Use closures for state encapsulation.
  • Avoid globals — use modules.
  • Avoid eval and Function — substantial security risks.

The combination — block-scoped let/const, module-scoped imports/exports, the closure mechanism, the lexical this of arrow functions, the strict-mode-by-default of modules, the conventional globalThis for cross-environment access — is the substance of JavaScript’s organisational model. The discipline produces well-encapsulated, modular code with substantial scoping flexibility through the closure mechanism and the substantial module integration.