skip to content

Why is `if (isDev) { import './debug.js'; }` a syntax error in an ES module, and why must the module specifier be a plain string literal rather than a variable or a concatenation?

level: middleimportance: must knowfreq 60%

answer

  1. declaration, not a function call
  2. the graph must be known before anything runs
  3. specifier is a literal, not an expression
  4. top-level items only
  5. there is a runtime form for the other case

basics

~20 s

ES module imports are static by design: an import declaration may appear only at the top level of a module, and its specifier must be a string literal. That lets the engine read the whole dependency graph out of the syntax and link it before running any code — which conditional or computed imports would make impossible.

solid answer

~50 s

The grammar only allows an `import` declaration as a top-level item of a module, and only with a string-literal specifier — so an import inside an `if`, a function, or a `try` block is a syntax error, and so is `import x from './locales/' + lang`, or even a template literal with no substitutions. The reason is the loading model: the engine has to discover, fetch, and link the entire graph *before* evaluating anything. A conditional import would require running code to know whether the module is needed, and a computed specifier would require running code to know *which* module — both would defeat the phase order that makes hoisting, live bindings, and load-time export checking possible. It also means the dependency graph is derivable from syntax alone, which is what tooling relies on. When the module or the decision is genuinely only known at runtime, that is what the promise-returning `import()` form exists for.

go deeper

for a junior

Recall the two hard rules: an import declaration lives at the top level of the module, and its path is written as a quoted string literal. Know that a runtime form exists for the cases those rules exclude.

for a middle

Explain the causal reason, not the rule: the engine must build and link the whole graph before executing anything, so both the presence of an import and its target have to be readable from syntax alone.

for a senior

Connect it to consequences you have relied on — load-time detection of broken imports, graph analysis by tooling, parallel fetching — and be able to advise when a team should reach for the runtime form instead of contorting the static one.

for a principal

Own the trade deliberately: static structure is what makes a JavaScript codebase analysable ahead of time, and every runtime-resolved module is a hole in that guarantee. Set the policy for where those holes are acceptable.

## What the grammar actually allows An `import` declaration is a *module item*: it may appear only directly at the top level of a module, never nested inside a block, function, loop, `try`, or class body. And its specifier is a `StringLiteral` — nothing else fits the grammar. All of these are rejected at parse time: ```js if (isDev) { import './debug.js'; } // not a top-level item function load() { import './late.js'; } // same import x from './locales/' + lang; // specifier is an expression const p = './m.js'; import y from p; // specifier is an identifier import z from `./m.js`; // template literal, not a string literal ``` The same top-level rule applies to `export`. Note that the failure is a *syntax* error: the file does not parse, so nothing in it runs, not even the lines above the offending one. That is different from a runtime error and different again from the load-time errors that come from a valid file whose imports cannot be matched. ## Why the restriction exists ES modules are loaded in phases: the graph is parsed and fetched, then linked, then evaluated. Every property people like about modules follows from that ordering, and every one of them needs the import list to be knowable *without executing anything*. **The graph must be discoverable from syntax.** To fetch dependencies — potentially in parallel, potentially across a network — the engine reads specifiers straight out of the parsed source. A specifier built from a variable would require evaluating that variable, which means running the module body, which cannot happen until its dependencies are loaded. That is a circular requirement with no bottom. **Linking needs a fixed set of names.** During linking, every import is resolved to a specific export in a specific module and errors like "no such export" are raised. A conditionally present import would mean the set of names in scope depends on runtime state — bindings could not be created up front, and load-time checking would collapse into runtime checking. **Hoisting and live bindings depend on it.** Imported bindings exist and are wired before any body runs; that is only meaningful when the wiring is complete and unconditional. **Static analysis becomes possible.** Because imports are declarations, not calls, a tool can compute the exact dependency graph and the exact set of used exports without executing a line — which is what makes whole-program analysis of a JavaScript codebase feasible at all. ## What to do instead There are two honest options. **Import unconditionally and branch on use.** The vast majority of "conditional imports" are not really conditional; they just want conditional *behaviour*: ```js import { debugLog } from './debug.js'; export function log(msg) { if (isDev) debugLog(msg); } ``` The cost is that the module is always in the graph and its body always runs, so keep its top level free of side effects. **Use the runtime form.** `import(specifier)` is an expression, not a declaration: it may appear anywhere, takes any expression as its specifier, and returns a promise for the module namespace. It is the intended escape hatch for a decision or a path that only exists at runtime — `const mod = await import(pathFromConfig)`. Because it is a runtime operation, it buys back the flexibility precisely by giving up everything the static form guaranteed: nothing about it can be resolved or checked before execution. ## The interview shape Candidates often answer only "because the spec says so". The answer that lands is the causal chain: static syntax → the graph is knowable without execution → the graph can be fetched and linked before evaluation → imports can be hoisted, bindings can be live, missing exports fail at load, and tools can analyse the program without running it. Then close the loop by naming the runtime form as the deliberate opt-out, and noting the trade it makes. A good sanity check on your own understanding: ask what would break if a specifier could be a variable. The engine would have to run the module to learn what the module depends on — an ordering that cannot be satisfied. That impossibility, not stylistic preference, is why the restriction is in the grammar.

  • Is a template literal with no substitutions, like `` import x from `./m.js` ``, accepted?
    No. The grammar requires a string literal specifically, so even a substitution-free template literal is a syntax error. The rule is about the syntactic form, not about whether the value happens to be constant — a parser must be able to read the specifier as text without evaluating any expression.
  • Does the same top-level restriction apply to `export` declarations?
    Yes. `export` is also a module item and may appear only at the top level of a module, never inside a block or function. The reason is the same: a module's export list has to be knowable statically so that importers can be linked to it before anything is evaluated.
  • If I want a module in the graph only in development, what are my realistic options?
    Either import it unconditionally and guard its use behind a flag, keeping its top-level body free of side effects, or bring it in at runtime with the promise-returning `import()` form inside your branch. There is no way to make a static `import` declaration conditional, because its whole value comes from being resolvable before execution.

saying these in an interview costs you the question

  • Says import can go anywhere as long as it is not in a loop
  • Thinks a const specifier is fine because the value never changes
  • Believes the restriction is only a linting convention
  • Calls import a function that returns the module
  • Claims wrapping it in try/catch makes a failed import recoverable

context