Back to JavaScript tutorials
Intermediate14 min read

ES Modules

Organize code with import/export syntax, dynamic imports, and module resolution in modern JavaScript runtimes.

Import and Export Syntax

ES modules use static `import` and `export` declarations analyzed at parse time, enabling tree shaking in bundlers. Named exports allow multiple bindings per file; default exports provide a single primary export per module.

Prefer named exports in application code — they improve refactoring support, auto-import accuracy, and avoid ambiguous default import names. Reserve default exports for framework entry points (pages, routes) where conventions require them.

  • Named exports for most application modules
  • Default exports for framework conventions
  • Static imports enable tree shaking
export function formatDate(d) { /* ... */ }
export const API_URL = '/api';

import { formatDate, API_URL } from './utils.js';

Module Scope and Strict Mode

Each module has its own top-level scope — variables do not become globals. Modules run in strict mode automatically, catching silent errors like assigning to undeclared variables.

Circular dependencies are allowed but dangerous: if module A imports B before B finishes initializing exports, A may receive live but uninitialized bindings. Restructure shared logic into a third module or use dependency injection to break cycles.

  • Modules are always strict mode
  • Top-level vars are module-scoped, not global
  • Avoid circular dependencies when possible

Dynamic import()

`import()` returns a Promise resolving to the module namespace object. Use it for code splitting, lazy-loading routes, and loading optional features only when needed.

Dynamic imports work in async functions and enable conditional loading — for example, loading a heavy chart library only when the user opens the analytics tab. Bundlers create separate chunks automatically from dynamic import sites.

  • Returns Promise of module namespace
  • Enables route and feature code splitting
  • Works conditionally at runtime
async function loadChart() {
  const { renderChart } = await import('./chart.js');
  renderChart(data);
}

Package.json Module Fields

Node.js and bundlers resolve modules using `"type": "module"` for `.js` ESM files or `.mjs` extension. `"exports"` in package.json controls public entry points and prevents deep imports into internal paths.

Dual packages (CJS + ESM) remain tricky — prefer ESM-first for new libraries. Use `"import"` and `"require"` conditions in `exports` when both are required for compatibility.

  • `"type": "module"` for ESM `.js` files
  • `exports` field controls public API
  • Prefer ESM-first for new packages

Production Module Patterns

Barrel files (`index.js` re-exporting everything) simplify imports but can hurt tree shaking and increase bundle size if they re-export large subgraphs. Prefer direct imports from source modules in performance-sensitive apps.

Use path aliases (`@/components`) via bundler/tsconfig configuration for readability without deep relative paths. Keep side effects explicit — modules that mutate global state on import should be documented and minimized.

  • Avoid heavy barrel files in hot paths
  • Path aliases for clean imports
  • Minimize import-time side effects

Get In Touch


Ready to discuss your next project? Drop me a message.