JAVASCRIPT / MODULES AND DEVELOPER TOOLING
Scripts, scopes, and why modules win
Explain why classic scripts share one global scope, how that makes load order a bug source, and what module scope changes about names and timing.
What you will learn
- Tell whether a top-level name lands on globalThis or stays private to the file
- Explain why load order decides which classic script wins a name collision
- Convert IIFE-wrapped scripts into modules without re-creating the wrapper
- List what module scope changes: strict mode, top-level this, single evaluation
Understanding Scripts, scopes, and why modules win
A classic `<script>` has no scope of its own. The browser evaluates the tags in document order and the top level of every one of them is the same global scope, so a top-level `var` or `function` becomes a property of `window` that any other file can read or overwrite. The useful mental model is concatenation: two files that both define `total` do not have two variables, they share one, and the tag that runs last decides its value. That is why the first line of the example prints `false` — `check` reads the global at call time, `5 <= 'ten'` compares against `NaN`, and nothing warned that another file had reassigned `limit`.
Before modules the workaround was to buy scope by hand: wrap each file in an immediately invoked function and hand back only the few names other code needed. That fixed privacy but left dependencies implicit — the order of the script tags was the dependency graph, maintained by convention, and a file that ran too early saw `undefined` instead of a loud failure. Nothing in the code stated that config.js had to run first, so no tool could check it.
An ES module makes that scope a language feature instead of a trick. Each module has its own top-level scope, so its names never land on `globalThis`; its body is strict mode whether you ask for it or not; top-level `this` is `undefined` rather than `window`; and it is evaluated once per URL no matter how many files pull it in. The larger win is that the import and export declarations are static, so the engine reads them before executing anything, builds the graph, and evaluates dependencies first — ordering comes from the code rather than from your tag order, and a name that does not exist fails while the graph is linked instead of surfacing as `undefined` at some later call site.
// One shared global scope: exactly what two classic <script> files get.
globalThis.limit = 10; // config.js
globalThis.check = (n) => n <= globalThis.limit; // config.js
globalThis.limit = 'ten'; // vendor.js, loaded second
console.log('scripts:', globalThis.check(5));
// The same two files given their own top-level scope by hand.
const config = (() => {
const limit = 10;
return { limit, check: (n) => n <= limit };
})();
const vendor = (() => {
const limit = 'ten';
return { limit };
})();
console.log('modules:', config.check(5), config.limit, vendor.limit);The top level of a classic script is the shared global scope while the top level of a module is private to its file, and that single difference is what converts hand-ordered implicit globals into a declared, checkable dependency graph.
Worked examples
The name exists, the value does not
Shows the classic load-order failure: a hoisted global is visible long before the file that assigns it has run.
console.log(typeof formatPrice);
try {
formatPrice(3);
} catch (err) {
console.log(err.constructor.name + ': ' + err.message);
}
var formatPrice = (n) => '$' + n.toFixed(2);
console.log(formatPrice(3));Example explained
Line 1`var formatPrice` is hoisted, so the name is already in scope with the value `undefined`.
Line 2The call fails as a `TypeError` at the call site, which is why this class of bug points at the consumer file rather than at the tag order that caused it.
Line 3The last line works only because the assignment has now executed — the value depends on when the script ran.
Line 4Imports are linked before any module body runs, so a name that is missing or misspelled is reported then, not as an `undefined` you have to chase.
Strict mode is the default in modules
Run as a classic script: an undeclared assignment quietly creates a global, while the same line under strict mode (what every module body gets) throws.
function sloppy() { hits = 1; return hits; }
function strictly() { 'use strict'; misses = 1; }
console.log(sloppy(), 'hits' in globalThis);
try {
strictly();
} catch (err) {
console.log(err.constructor.name + ': ' + err.message);
}Example explained
Line 1`hits = 1` declares nothing, so sloppy mode creates `globalThis.hits`; the `in` check confirms the leak.
Line 2`'use strict'` inside `strictly` reproduces what a module body has automatically, so the same pattern throws.
Line 3The throw happens on the line with the typo, which is what makes accidental cross-file globals traceable instead of mysterious.
Line 4Move this file to a module and even `sloppy()` throws, because module code is strict all the way through.
Important notes
`let` and `const` at the top level of a classic script never become `window` properties, but they still live in the one shared global scope, so two files that both declare `const config` fail with "Identifier 'config' has already been declared".
A file is a module because of how it is loaded — `type="module"`, an `.mjs` extension, or `"type": "module"` — not because it contains import or export; load module code with a plain script tag and the first `import` is a SyntaxError.
Common mistakes
Adding `type="module"` but leaving `onclick="save()"` in the HTML: the attribute looks `save` up on `window`, module scope keeps it private, so every click throws "save is not defined".
Writing `this.APP = {}` at the top of a module expecting `this` to be `window`: top-level `this` is `undefined` in a module, so you get "Cannot set properties of undefined" instead of a namespace.
Assuming a module tag runs where it sits: `type="module"` is deferred, so it executes after the document is parsed and after every classic inline script, and anything that used to depend on it running early breaks.
Try it yourself
Change, predict, then run
Build an HTML file with two inline script tags, the first `var mode = 'a'; console.log(mode, window.mode)` and the second the same with `'b'`, and note the four values logged. Then add `type="module"` to both tags, reload, and explain why the `window.mode` half of each line changed.
Open the JavaScript workspaceCheck your understanding
Two files loaded on the same page each begin with `var apiBase = ...` at the top level. What happens, and what changes if both are loaded as modules?
- As classic scripts they are one global property, so whichever tag runs last sets the final value; as modules each file has its own binding and neither appears on window
- As classic scripts the duplicate declaration is a SyntaxError, while modules allow it because each file is parsed separately
- Either way there are two independent variables, because `var` is function-scoped and each file counts as its own function
- As classic scripts the first assignment wins, because a later `var` for a name that already exists is ignored
Show answer
At the top level of a classic script there is no enclosing function, so `var` targets the global object and both files write the same property; hoisting makes the second declaration a no-op but its assignment still executes, which is why the option saying the first assignment wins is wrong — only the declaration is skipped, never the assignment. Module scope removes the shared binding entirely, giving each file its own `apiBase` that is invisible on `window`.