ES Module Import Patterns

Interactive playground for modern JavaScript module system patterns

Static Import Declarations

Static imports are hoisted, evaluated at parse time, and create live bindings to the exported values.

Named Imports

import { useState, useEffect } from 'react';
import { readFile as read } from 'node:fs/promises';

Default Import

import React from 'react';
import myDefault, { named1, named2 } from './module.js';

Namespace Import

import * as utils from './utils.js';
// utils.functionA(), utils.functionB()

Side-effect Import

import './polyfills.js';
// Executes module code, imports nothing
Static imports are hoisted to the top of the module. They create live bindings: if the exporting module changes the value, the importing module sees the change.

Interactive Demo: Live Bindings

Demonstrate how static imports create live bindings (unlike CommonJS copies):

Dynamic import()

Dynamic import returns a Promise that resolves to the module namespace object. It enables lazy loading, conditional loading, and computed specifiers.

Static Import parse-time

// Must be top-level
// Specifier must be string literal
// Always loaded
import { heavy } from './heavy.js';

Dynamic import() runtime

// Can be anywhere
// Specifier can be expression
// Loaded on demand
const { heavy } = await import('./heavy.js');

Use Cases

// 1. Conditional loading
if (user.isAdmin) {
  const { AdminPanel } = await import('./admin.js');
}

// 2. Computed specifier
const locale = navigator.language;
const messages = await import(`./i18n/${locale}.js`);

// 3. Lazy loading on event
button.addEventListener('click', async () => {
  const { showChart } = await import('./chart.js');
  showChart(data);
});

Interactive Demo: Simulated Dynamic Loading

Import Assertions / Attributes

Import assertions (now called import attributes in the latest spec) let you specify metadata about the module being imported, such as its type.

Spec evolution: The assert keyword was renamed to with in the final spec. Both may appear in the wild.

JSON Import

// Old syntax (deprecated)
import data from './data.json' assert { type: 'json' };

// New syntax (import attributes)
import data from './data.json' with { type: 'json' };

// Dynamic import with attributes
const data = await import('./data.json', {
  with: { type: 'json' }
});

CSS Module Import

import sheet from './styles.css' with { type: 'css' };
document.adoptedStyleSheets = [sheet];

Browser Support

TypeSyntaxUse CaseStatus
json with { type: 'json' } Import JSON as module default Shipped
css with { type: 'css' } Import CSS as CSSStyleSheet Partial
source with { type: 'source' } Import as WebAssembly source Proposal

Import Maps

Import maps let you control module resolution in the browser — mapping bare specifiers to URLs, creating scoped overrides, and managing package versions.

<!-- Must appear before any module scripts -->
<script type="importmap">
{
  "imports": {
    "lodash": "https://cdn.esm.sh/[email protected]",
    "react": "https://esm.sh/react@18",
    "@utils/": "./src/utils/"
  },
  "scopes": {
    "/legacy/": {
      "lodash": "https://cdn.esm.sh/[email protected]"
    }
  }
}
</script>

Key Features

Bare Specifiers

Map import 'lodash' to a CDN URL — just like Node.js but in browsers.

Path Prefixes

Map @utils/ to ./src/utils/ for clean import paths.

Scoped Overrides

Different versions for different parts of your app — resolve dependency conflicts.

Integrity

Add "integrity" block for subresource integrity checks on mapped modules.

Import Map Resolution Simulator

Top-Level Await

Top-level await allows using await at the top level of a module, turning the module itself into an async function.

// config.js — module blocks until fetch completes
const response = await fetch('/api/config');
export const config = await response.json();

// app.js — waits for config.js to resolve
import { config } from './config.js';
console.log(config); // guaranteed resolved

Execution Model

Parse modules
Execute deps (may await)
Execute current module
Module ready

Without TLA

// Must wrap in async IIFE
let config;
(async () => {
  const r = await fetch('/config');
  config = await r.json();
})();
// Race condition: config may be undefined

With TLA Better

// Clean, no race condition
const r = await fetch('/config');
export const config = await r.json();
// Importers always see resolved value

Interactive Demo: Top-Level Await Simulation

Module Namespace Objects

When you use import * as ns from 'module', you get a module namespace object — a sealed, non-extensible object with a null prototype.

import * as math from './math.js';

// Namespace object properties:
typeof math;                    // "object"
Object.getPrototypeOf(math);   // null (Module namespace exotic object)
math[Symbol.toStringTag];      // "Module"
Object.isSealed(math);         // true (in strict sense)

// Live bindings still apply:
math.counter;  // reflects current value from the module

// Cannot modify namespace:
math.newProp = 42;  // TypeError in strict mode
delete math.add;     // TypeError

Namespace Object Properties

PropertyValueNotes
@@toStringTag "Module" Identifies as Module namespace
[[Prototype]] null No prototype chain
default The default export Only if module has default export
Named exports Live binding getters Read-only from outside

Interactive Demo

Re-export Patterns

Re-exports allow you to aggregate exports from multiple modules into a single entry point — the "barrel file" pattern.

// components/index.js — Barrel file

// Named re-export
export { Button } from './Button.js';
export { Input, Textarea } from './Input.js';

// Re-export with rename
export { default as Card } from './Card.js';

// Wildcard re-export (excludes default)
export * from './utils.js';

// Wildcard with namespace (Stage 3)
export * as utils from './utils.js';

Re-export Pattern Comparison

PatternSyntaxIncludes Default?Tree-Shakeable?
Named export { X } from 'mod' Only if explicit Yes
Wildcard export * from 'mod' No Partial
Namespace export * as ns from 'mod' Yes No
Default relay export { default } from 'mod' Yes Yes

Interactive: Build a Barrel File

Circular Dependencies

ES modules handle circular dependencies through live bindings. Unlike CommonJS (which copies values), ESM creates references that are resolved when accessed.

The Problem

a.js
→ imports →
b.js
b.js
→ imports →
a.js

CommonJS Copies

// a.js (CommonJS)
const { b } = require('./b');
console.log(b); // undefined!
exports.a = 'A';

// b.js (CommonJS)
const { a } = require('./a');
console.log(a); // undefined!
exports.b = 'B';

ESM Live Bindings

// a.js (ESM)
import { b } from './b.js';
export const a = 'A';
console.log(b); // 'B' ✓

// b.js (ESM)
import { a } from './a.js';
export const b = 'B';
console.log(a); // TDZ error if a not init'd!
Key insight: ESM live bindings solve some circular dependency issues, but execution order still matters. If module B tries to read a before module A has executed the const a = 'A' line, you get a TDZ (Temporal Dead Zone) error.

Interactive: Circular Dependency Simulator

Module Evaluation Order

ES modules are evaluated in post-order depth-first traversal. Dependencies are evaluated before the modules that import them.

Example Module Graph

// main.js imports a.js and b.js
// a.js imports c.js
// b.js imports c.js (shared dependency)

// Evaluation order:
// 1. c.js (leaf — evaluated first)
// 2. a.js (c.js already done)
// 3. b.js (c.js already done, skipped)
// 4. main.js (all deps ready)

Evaluation Steps

1
Parse
2
Instantiate
3
Evaluate
  • Parse: Build module record, find imports/exports
  • Instantiate: Create live bindings (memory allocated, not yet initialized)
  • Evaluate: Execute module body (depth-first, post-order)

Interactive: Module Graph Evaluator