skip to content

At the top level of a classic (non-module) script, var declarations become properties of globalThis but let and const do not. What problems does that cause, and how does the picture change in an ES module?

level: seniorimportance: should knowfreq 45%

answer

  1. two records behind one global scope
  2. only one keyword makes a property
  3. non-configurable, so undeletable
  4. modules change the answer entirely

basics

~20 s

Top-level var in a classic script creates a non-configurable property on the global object, so separate scripts can collide with each other and with built-in globals. let and const instead create bindings in a separate global scope that is not reachable as a property. In an ES module neither touches globalThis.

solid answer

~50 s

The global scope has two halves. Top-level `var` (and top-level function declarations) go into the **object** half — they become real properties of `globalThis`, enumerable and writable but **non-configurable**, so `delete globalThis.x` returns false and the name cannot be removed. Top-level `let`, `const` and `class` go into a separate **declarative** half: the binding is visible to code as a plain identifier, but `globalThis.x` is `undefined` and no property exists. The practical fallout is collision. Two independently authored classic scripts share one global scope, so a top-level `var config` in each silently overwrites the other, and a top-level `var` whose name matches an existing global property assigns through that property rather than creating a fresh one. In an ES module the top level is module scope, so `var`, `let` and `const` all stay local and nothing reaches `globalThis` unless you assign to it explicitly.

code

javascript · 11 lines
javascript
// Top level of a classic (non-module) script
var   a = 1;
let   b = 2;
const c = 3;

console.log(globalThis.a);               // 1
console.log(globalThis.b, globalThis.c); // undefined undefined
console.log('b' in globalThis);          // false

console.log(Object.getOwnPropertyDescriptor(globalThis, 'a').configurable);
// false — the property cannot be deleted or redefined

go deeper

for a junior

Know the observable fact: after a top-level var in a plain script, globalThis.x has the value, while a top-level let or const leaves globalThis.x undefined even though the name works normally.

for a middle

Explain the two-record global scope, name the property attributes var produces — writable, enumerable, non-configurable — and state that module top level is not global scope at all.

for a senior

Show the operational consequences: silent cross-script collisions, a var binding through a pre-existing global property, names that can never be deleted, and how moving to modules removes the whole class.

for a principal

Own the boundary policy — decide what your bundle is allowed to publish on globalThis, treat each such name as public API with a collision-resistant prefix, and be able to justify why implicit globals are never the mechanism.

## The global scope is two records, not one When the engine sets up the global scope it creates two environment records that behave as one lookup chain: - an **object record** backed by the global object (`globalThis`). Anything bound here is literally a property of that object. - a **declarative record**, an ordinary scope with no object behind it. Which record a top-level declaration lands in depends on the keyword. `var` and function declarations go into the object record. `let`, `const` and `class` go into the declarative record. Identifier lookup consults the declarative record first, then the object record, which is why both kinds of name resolve as plain identifiers and the split is invisible until you go looking through `globalThis`. ```js // top level of a classic script var a = 1; let b = 2; const c = 3; console.log(a, b, c); // 1 2 3 console.log(globalThis.a); // 1 console.log(globalThis.b, globalThis.c); // undefined undefined console.log('b' in globalThis); // false ``` ## The property var creates is not an ordinary one A global created by `var` is writable and enumerable but **non-configurable** — it cannot be deleted or redefined: ```js var a = 1; console.log(Object.getOwnPropertyDescriptor(globalThis, 'a')); // { value: 1, writable: true, enumerable: true, configurable: false } delete globalThis.a; // false in sloppy mode; TypeError in strict mode ``` Contrast that with an accidental global — an assignment to an undeclared name in sloppy mode — which creates a *configurable* property that `delete` removes. (Under strict mode that assignment is a ReferenceError instead.) So the two ways to get a global differ not only in how they read but in whether they can ever be undone. ## Why this hurts: collisions Every classic script on a page shares one global scope, and they are evaluated in order into it. That gives three distinct failure modes. **Silent overwrite between scripts.** Two files that each declare `var config = {...}` at top level end up sharing one binding; the second one wins, and nothing anywhere reports a problem. The equivalent with `let` is louder: because the declarative record is shared too, a second script declaring `let config` when one already exists throws a SyntaxError when that script is evaluated. Noisy failure is the better failure. **Collision with existing global properties.** The host's global object already carries a large set of properties. A top-level `var` whose name matches one of them does not create a fresh binding — the property already exists, so the declaration leaves it in place and the initializer assigns *through* it. In a browser page, `var name = 42` at top level writes to the pre-existing `name` property of the global object, and reading `name` back gives the string `"42"` rather than the number you assigned. With `let name = 42` there is no such interaction, because the binding is created in the declarative record and shadows the global property entirely. **Unremovable names.** Because the property is non-configurable, a script that declares a top-level `var` has permanently claimed that name on the global object for the lifetime of the page. Test harnesses and sandboxes that try to reset global state between runs cannot delete it. ## What changes in an ES module Module code has its own top-level scope. `var`, `let` and `const` at the top level of a module are all module-scoped: none of them appears on `globalThis`, and two modules may each declare `var config` with no interaction whatsoever. ```js // inside an ES module var a = 1; console.log(globalThis.a); // undefined ``` The same is true of any module system that wraps each file's code in a function before evaluating it — the wrapper's function scope catches the `var`. This is the single biggest reason the collision problem has largely vanished from modern codebases: almost nothing ships as a classic top-level script any more. ## Doing it deliberately If a module genuinely needs to publish something globally — a debugging handle, a polyfill, an integration point for a script you do not control — assign to `globalThis` explicitly: ```js globalThis.__APP_DEBUG__ = { version, flushCaches }; ``` This is better than relying on `var` even where `var` would work: it is searchable, it states the intent, and the property it creates is configurable, so it can be removed again. Treat every such assignment as a public API of your bundle and give it a name unlikely to collide. ## The interview shape of this The question is usually asked as "why does `globalThis.b` come back undefined when `b` is clearly declared?" The answer is the two-record split, and the follow-through that shows seniority is the operational consequence: `var` globals are non-configurable, they collide silently across scripts, they can bind through existing host properties, and none of it applies once your code is modules.

  • Two classic scripts on the same page each declare a top-level let with the same name. What happens?
    The second one fails. Top-level lexical declarations from every classic script share one global declarative record, so the second script throws a SyntaxError for a duplicate declaration when it is evaluated — the first script's binding is already there. With `var` the same pair is silently accepted and the two scripts share one binding, which is precisely the failure mode lexical declarations were meant to surface.
  • Does an undeclared assignment create the same kind of global as a top-level var?
    No. In sloppy mode `x = 1` on an undeclared name creates a **configurable** property of the global object, so `delete globalThis.x` succeeds. A top-level `var x = 1` creates a non-configurable one that cannot be deleted. In strict mode the undeclared assignment is not a global at all — it throws a ReferenceError.
  • From inside a module, how would you deliberately expose something globally?
    Assign to `globalThis` explicitly: `globalThis.__APP_DEBUG__ = handle;`. Nothing a module declares at its top level reaches the global object, so the assignment is the whole mechanism. It is also the better style even where a var would have worked — it is greppable, it states intent, and the property it creates is configurable and therefore removable.

saying these in an interview costs you the question

  • Says let and const also land on globalThis
  • Claims top-level var globals can be deleted
  • Thinks module top-level var attaches to the global object
  • Says globalThis.b is undefined because b is undefined
  • Believes two scripts get separate global scopes

context