Interactive playground for modern JavaScript module system patterns
Static imports are hoisted, evaluated at parse time, and create live bindings to the exported values.
import { useState, useEffect } from 'react'; import { readFile as read } from 'node:fs/promises';
import React from 'react'; import myDefault, { named1, named2 } from './module.js';
import * as utils from './utils.js'; // utils.functionA(), utils.functionB()
import './polyfills.js'; // Executes module code, imports nothing
Demonstrate how static imports create live bindings (unlike CommonJS copies):
Dynamic import returns a Promise that resolves to the module namespace object. It enables lazy loading, conditional loading, and computed specifiers.
// Must be top-level // Specifier must be string literal // Always loaded import { heavy } from './heavy.js';
// Can be anywhere // Specifier can be expression // Loaded on demand const { heavy } = await import('./heavy.js');
// 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); });
Import assertions (now called import attributes in the latest spec) let you specify metadata about the module being imported, such as its type.
assert keyword was renamed to with in the final spec. Both may appear in the wild.
// 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' } });
import sheet from './styles.css' with { type: 'css' }; document.adoptedStyleSheets = [sheet];
| Type | Syntax | Use Case | Status |
|---|---|---|---|
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 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>
Map import 'lodash' to a CDN URL — just like Node.js but in browsers.
Map @utils/ to ./src/utils/ for clean import paths.
Different versions for different parts of your app — resolve dependency conflicts.
Add "integrity" block for subresource integrity checks on mapped modules.
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
// Must wrap in async IIFE let config; (async () => { const r = await fetch('/config'); config = await r.json(); })(); // Race condition: config may be undefined
// Clean, no race condition const r = await fetch('/config'); export const config = await r.json(); // Importers always see resolved value
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
| Property | Value | Notes |
|---|---|---|
@@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 |
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';
| Pattern | Syntax | Includes 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 |
ES modules handle circular dependencies through live bindings. Unlike CommonJS (which copies values), ESM creates references that are resolved when accessed.
// 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';
// 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!
a before module A has executed the const a = 'A' line, you get a TDZ (Temporal Dead Zone) error.
ES modules are evaluated in post-order depth-first traversal. Dependencies are evaluated before the modules that import them.
// 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)