skip to content

A JavaScript file that worked when loaded as a classic script now throws ReferenceError once the same code is loaded as an ES module — once on a helper function another file defines, and once on a line that assigns to a name without declaring it. What changed, and how do you fix each?

level: seniorimportance: should knowfreq 42%

answer

  1. two script-era assumptions, not one bug
  2. cross-file names no longer resolve
  3. undeclared assignment now throws
  4. declare, then export and import
  5. some breakage never throws at all

basics

~20 s

Two module rules bit at once. Module scope means the other file's helper is no longer visible as a bare name, so it must be exported and imported; implicit strict mode turns the undeclared assignment from a silent global into a ReferenceError, so declare it with const or let.

solid answer

~40 s

Both errors come from module semantics, not from a bug you introduced. First, a module body has its own top-level scope: the helper another file declared no longer lands in a shared namespace, so the name simply does not resolve — the fix is to `export` it from that file and `import` it here, not to reach for `globalThis`. Second, module code is strict, so `total = 0` without a declaration is a `ReferenceError` rather than a quiet global-object write; declare it with `const` or `let`, and check whether anything else was depending on that accidental global. The useful diagnostic order is: run the file as a script with `'use strict'` added first, which surfaces every strict violation while the shared namespace still exists, then convert to a module and fix the cross-file references.

code

javascript · 14 lines
javascript
// helpers.js — was a classic script relying on the shared namespace
export function helper(x) {
  return x * 2;
}

// consumer.js — the migrated file
import { helper } from './helpers.js';

// was: total = 0;  -> ReferenceError under module strictness
let total = 0;
total = helper(21);

console.log(total);                     // 42
console.log(typeof globalThis.helper);  // "undefined" — nothing leaked

go deeper

for a junior

Recognise the two causes by name: the other file's function needs an export and an import, and the bare assignment needs a let or const declaration because module code is strict.

for a middle

Explain why each rule produces its specific error — no shared namespace for the unresolved name, strict mode for the assignment — and why the pair together is what stops a module from touching the global namespace.

for a senior

Show a sequencing plan that separates the two failure classes, and demonstrate that you look past the thrown errors to the silent breakage: consumers of globals this file used to create, typeof-guarded branches, and captured top-level this.

for a principal

Frame it as a migration strategy question: decide the order files convert in, define the single sanctioned global-boundary file with an expiry, and put a lint rule in place so implicit globals cannot come back once the codebase is converted.

## Two independent rules, one migration The file did not change; the semantics it is evaluated under did. Loading the same source as a module applies two rules that script code does not have, and they fail in different ways. **Rule one: the module body has its own top-level scope.** In script code, top-level `var` and function declarations became properties of the global object, so file B could call `helper()` purely because file A had been evaluated first. In module code every declaration stays in the module's own environment record, so `helper` never enters a shared namespace and the reference does not resolve. **Rule two: module code is strict.** In sloppy mode, assigning to a name that was never declared creates a property on the global object. Under strict mode that is a `ReferenceError`. A file can rely on this for years without anyone noticing, because the "declaration" happens implicitly on first assignment. The two rules reinforce each other, which is the real point: scope stops declarations from leaking out, and strictness closes the assignment back door. Together they mean a module cannot contribute to the global namespace by accident. The migration pain is that the old file was built on exactly the behaviour the pair removes. ## Fixing the cross-file reference The legitimate fix is an explicit dependency: ```js // helpers.js export function helper(x) { return x * 2; } // consumer.js import { helper } from './helpers.js'; console.log(helper(21)); ``` The tempting non-fix is `globalThis.helper = helper` in the first file. It makes the error go away and preserves the exact fragility you were migrating away from: an implicit ordering requirement, invisible in the source, that breaks the moment loading order changes. Reserve global assignment for a genuine boundary — a handle non-module code on the page still reads — and keep it to one file you can grep and eventually delete. Watch for the two-way case: file A calls into B and B calls back into A. Converting one side at a time means a temporary period where a name resolves in one direction and not the other, which produces confusing half-working states. Convert both ends of a pair in the same change. ## Fixing the undeclared assignment ```js // before — worked by accident in sloppy script code total = 0; // after let total = 0; ``` Declare it, but do not stop there: ask whether anything else read that accidental global. If another file was reading `total` off the global object, declaring it locally fixes this file and silently breaks that one — you have moved the failure rather than removed it. That consumer needs an import, and the value needs an export. Also check whether the undeclared name was a typo all along. Sloppy mode's most expensive behaviour is turning a misspelled assignment into a brand-new global that nothing reads, so the intended variable keeps its old value. Strict mode surfacing it as a `ReferenceError` is the migration paying for itself. ## A diagnostic order that saves time The reason these migrations feel chaotic is that both rules fire at once and the errors interleave. Separate them: 1. **Keep the file as a script and add `'use strict'` at the top.** The shared namespace still exists, so cross-file references keep working, and every strict violation — undeclared assignments, writes to frozen objects, `with`, legacy octals, duplicate parameters — surfaces on its own. Fix that batch first. 2. **Now convert to a module.** Every remaining `ReferenceError` is, by construction, a cross-file reference that needs a real import. The error list has become a to-do list. 3. **Sweep the leftovers.** Replace top-level `this` with `globalThis` or delete it; convert whatever the file published to the global object into exports; delete the now-dead `'use strict'` pragma, which is a no-op in module code. Splitting the work this way turns one confusing failure mode into two boring ones. ## Failures that do not announce themselves Not every consequence throws. Reading a name that was never declared is only a `ReferenceError` in an expression position; `typeof missing` still returns `"undefined"` quietly, so feature-detection branches can silently take the wrong path. Code that assumed top-level `this` was the global object now captures `undefined` and fails far from the cause. And anything that depended on the old file publishing a global — a plugin registry, an inline handler in markup, a debugging hook someone used from the console — stops working with no error at all, because nothing throws when a global simply never appears. That last category is why the honest answer to this interview question includes "and then I check what depended on the globals this file used to create." Fixing the two ReferenceErrors is the easy half.

  • Why not just assign the helper to `globalThis` and skip the import rewrite?
    Because it keeps the exact fragility you are migrating away from: an unwritten load-order requirement that breaks when files move or load lazily, and a dependency no tool can see. An import is checked, greppable and analysable. Reserve globalThis for a single deliberate boundary where non-module code must still reach in.
  • After you declare the previously-undeclared variable, what else should you check?
    Whether anything outside the file was reading it off the global object. Declaring it locally fixes this file and can silently break that consumer, since nothing throws when an expected global simply never appears. That consumer needs an import and the value needs an export.
  • Which consequences of the move do not throw at all?
    Reads guarded by `typeof`, which still return `"undefined"` and quietly take the wrong branch; top-level `this` captured as `undefined` and failing much later; and anything that depended on this file publishing a global — inline handlers, plugin registries, console debugging hooks — which simply stops working with no error.
  • How would you sequence the migration to avoid interleaved failures?
    Add `'use strict'` while the file is still a script: the shared namespace survives, so only strict violations surface and you fix them as one batch. Then convert to a module, at which point every remaining ReferenceError is by construction a cross-file reference needing an import. Two boring passes instead of one confusing one.

saying these in an interview costs you the question

  • Reaches for globalThis assignments instead of exports
  • Blames the bundler rather than module semantics
  • Thinks declaring the variable locally ends the investigation
  • Assumes every breakage from the move throws an error
  • Converts one side of a mutually-referencing pair at a time

context