JavaScript Modules Table
| Syntax | What it does | Field note |
|---|---|---|
export const x = 1 | Named export | The declaration stays live - importers see updates |
export default value | Module's main export | One per module; imported with any name you choose |
export { a as b } | Rename on the way out | Public API name decoupled from internal name |
import { x } from 'm' | Named import | Braces and name must match the export exactly |
import x from 'm' | Default import | No braces; the name is local - conventions say x = module name |
import * as m from 'm' | Namespace import | Everything named, on one object; defaults ride m.default |
import('m') | Dynamic import | Returns a promise - code-splitting and lazy routes live here |
export * from 'm' | Re-export | Barrel files (index.js) are built from this |
<script type="module"> | Turn a script into a module | Strict mode + own scope + deferred by default |
import maps | Map specifiers to URLs | Lets bare names like 'lodash' work in the browser |
ES modules are JavaScript's built-in packaging: each file is a module with its own scope, imports and exports form a dependency graph, and the browser loads that graph without any tooling. The table below is the complete working syntax - ten forms that cover everything from a first import statement to barrel files and code-splitting.
Bottom line: the default-vs-named choice is a design statement. A default export says this module IS one thing - a component, a class, a single function; the importer names it, so conventions carry the meaning. Named exports say this module is a collection of utilities; the names are fixed, refactoring-safe, and tooling can tree-shake them precisely. Most mature codebases lean named, keeping default for the one obvious thing a module exists to provide.
The honest part: two behaviors surprise people coming from CommonJS. Module imports are LIVE bindings - if the exporting module changes a value, importers see the update, which is nothing like a copied require(). And everything inside a type="module" script is deferred and strict by default: it runs after the DOM is parsed, which quietly retires the window-onload boilerplate an entire generation memorized.
How to use
- Match the direction you are going: export rows define a module's surface, import rows consume someone else's.
- Pick default vs named by the design rule in the note - one obvious thing defaults to default; utilities default to named.
- Use dynamic import() when the timing matters: routes, modals, and heavy libraries load only when first needed.
Frequently asked questions
When should I use export default versus named exports?
Default marks the module's single identity: Button.js exports the Button, dateUtils.js would export a default only if one function dominated. Named exports mark collections: api.js exporting fetchUser, deleteUser, updateUser is better served by three names than one ambiguous default object. Two practical tiebreakers: named imports cannot be misnamed (the compiler checks the spelling), and bundlers tree-shake unused named exports more reliably - a default object with twelve methods ships all twelve.
What is a barrel file and when does it hurt?
A barrel is an index.js that re-exports a folder's modules (export * from './user'). It gives importers one short path - import { User } from './components'. The cost appears at scale: every barrel entry loads on import unless your bundler can tree-shake through it, and circular references between barrels and modules produce the infamous undefined-at-import bug. Small folders: barrel freely. Large apps: import from the real paths, or keep barrels per-feature rather than one global one.
What are circular imports and how do I fix them?
Module A imports B while B imports A. ES modules handle this without crashing - each module sees the other's LIVE bindings - but whichever module executes first reads undefined for values the other has not initialized yet. The symptom is a class or function being undefined exactly once at startup. Fixes, in order of preference: extract the shared piece into a third module both import; pass the dependency in as a parameter; or defer the use to call time rather than module-evaluation time - live bindings mean late access works.
Why does import() take a promise while import does not?
Static imports are resolved before your code runs - the whole graph loads, links, and evaluates first; there is no moment where a module is missing. import() is runtime: it fetches and evaluates the module on demand, so the request itself is asynchronous and returns a promise of the module namespace. That asynchrony is the feature: routes, modals and heavy editors wait for import() until first use, and bundlers split each dynamic import into its own downloadable chunk - the entire code-splitting mechanism in one line.