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 blocks —
if,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
letwhen 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
awaitadmitted — admit substantial async initialisation. - Each module is its own scope — no shared globals.
thisisundefinedat module top level (vswindowin 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
constby default;letfor reassignment; nevervar. - Use ES modules (
type="module"). - Use named exports over default exports — admit substantial refactoring.
- Use
import.meta.urlfor module-relative paths. - Use top-level
awaitfor 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
evalandFunction— 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.