ESC

Type to search the knowledge base.

ES Modules

import/export semantics — live bindings, default vs named, strict mode, module graph loading, and script type=module.

beginner3 min read
  • javascript
  • es-modules
  • import
  • export

ES modules (import / export) are the language-standard module system. They are strict mode by default, file-scoped, asynchronously loaded in browsers via the module graph, and they export live bindings — not copies of values. That last point is the interview gold.

Named and default exports

// math.js
export const PI = 3.14159;
export function add(a, b) {
  return a + b;
}

export default function sum(arr) {
  return arr.reduce((a, b) => a + b, 0);
}
// app.js
import sum, { add, PI } from './math.js';
import * as math from './math.js';

add(1, 2);
math.PI;

You can default-export any one value. Named exports can be many. Renames:

import { add as plus } from './math.js';
export { add as sum } from './math.js'; // re-export
export * from './math.js';

Live bindings

// counter.js
export let count = 0;
export function inc() {
  count += 1;
}
import { count, inc } from './counter.js';
console.log(count); // 0
inc();
console.log(count); // 1 — binding updated
// count = 2; // SyntaxError — import is read-only binding

The importer sees updates to the exported binding. Objects are still mutable by property if exported as const obj.

Browser loading

<script type="module" src="/app.js"></script>
  • Modules are deferred by default (like defer)
  • Specifiers usually need explicit paths/extensions in browsers: ./foo.js
  • Classic scripts and modules don’t share scope
// only works as a module
import { x } from './x.js';

Static structure

import declarations are hoisted and static — you cannot import inside a non-module branch with the declaration form:

// illegal
if (cond) {
  import x from './x.js';
}

// legal dynamic
if (cond) {
  const x = await import('./x.js');
}

Static imports let bundlers tree-shake. See dynamic import.

Circular dependencies

Modules can form cycles. Bindings may be in the temporal dead zone until initialized — calling a function before its module finishes evaluating can throw or see incomplete state. Prefer breaking cycles with dependency inversion.

CommonJS contrast (Node mental model)

ESM CJS
import/export require/module.exports
live bindings copied exports object snapshot-ish
async graph in browsers sync require
this at top level is undefined varies

Node supports both; dual packages have pitfalls (require of ESM).

Interview answer

“ES modules use static import/export, strict mode, and live bindings so importers see updates to exported lets. Default export is one value; named exports are many. Browsers load type=module deferred as a graph. Dynamic import() is for conditional/lazy loading. Imports are read-only bindings.”

import.meta and assertions

import.meta.url; // module's URL — resolve relative assets
const url = new URL('./worker.js', import.meta.url);

// JSON modules (supported in modern tooling/browsers)
// import data from './config.json' with { type: 'json' };

import.meta.env is a bundler convention (Vite), not language core. Side-effect imports (import './polyfill.js') run the module for effects only — use sparingly and keep polyfills idempotent. Tree-shaking drops unused named exports when side-effect free; mark pure packages accordingly in library package.json.

Further reading

Related guides