JavaScript · Syntax · Advanced

Modules

10 tasks

Work with ES modules: named and default exports/imports, re-exports, dynamic `import()`, and avoiding circular dependencies.

Why modules, named/default exports, imports, barrel re-exports

#
**Why modules exist** Before ES modules, every `<script>` tag shared a single global scope. Libraries had to namespace everything under one global variable (`jQuery`, `_`, `React`) to avoid collisions, and loading order mattered critically. Modules solve this by giving each file its own scope — nothing leaks to the global unless explicitly exported: ```js // Without modules (old scripts): everything global, collision risk var PI = 3.14; // collides if another script declares PI function add(a, b) { } // pollutes window.add // With modules: completely isolated const PI = 3.14; // only visible inside this file export function add(a, b) { return a + b; } // explicitly shared ``` **Named and default exports** ```js // --- math.js --- export function add(a, b) { return a + b; } // named export function sub(a, b) { return a - b; } // named export const PI = 3.14159; // named export default class Calculator { // default (one per module) add(a, b) { return a + b; } } ``` **Importing** ```js import { add, PI } from './math.js'; // named import { add as sum } from './math.js'; // renamed import * as math from './math.js'; // namespace object import Calculator from './math.js'; // default (any name) import Calculator, { add, PI } from './math.js'; // both math.add(1, 2); // via namespace math.PI; // 3.14159 ``` **Re-exports — barrel files** A barrel file (`index.js`) aggregates exports from multiple files into one entry point, so consumers import from one place instead of navigating file paths: ```js // --- src/utils/index.js (barrel) --- export { add, PI } from './math.js'; export { default as Calculator } from './math.js'; export * from './strings.js'; // all named exports from strings.js // --- consumer --- import { add, Calculator } from './src/utils'; // one clean import ```

Dynamic import(), top-level await, import.meta, module caching

#
**Dynamic `import()` — load on demand** Static imports run at module load time. `import()` returns a Promise and lets you load a module only when actually needed — great for code-splitting: ```js // Conditional loading async function loadChart() { if (!userWantsChart) return; const { default: Chart } = await import('./chart.js'); return new Chart(); } // Parallel loading — both start at the same time const [{ default: A }, { default: B }] = await Promise.all([ import('./a.js'), import('./b.js'), ]); // Dynamic path (computed at runtime) const locale = navigator.language.slice(0, 2); // 'uk', 'en', ... const { messages } = await import(`./locales/${locale}.js`); ``` **Error handling with dynamic import** ```js try { const mod = await import('./heavy-plugin.js'); mod.init(); } catch (err) { console.warn('Plugin failed to load, continuing without it', err); } ``` **Top-level `await`** Inside a module (not inside a function), you can `await` directly. The module graph waits for it to settle before any importer runs: ```js // --- db.js --- const connection = await connectToDatabase(config); // runs once at load export { connection }; // --- main.js --- import { connection } from './db.js'; // guaranteed to be ready ``` **`import.meta` — module metadata** ```js import.meta.url; // full URL of this module file import.meta.env; // Vite/bundler: environment variables import.meta.env.DEV; // true in development // Resolve a path relative to this module's location: const dataPath = new URL('./data.json', import.meta.url).pathname; ``` **Module caching** A module is evaluated only once, no matter how many times it's imported. All importers share the same module instance: ```js // counter.js let count = 0; export const inc = () => ++count; export const get = () => count; // a.js import { inc } from './counter.js'; inc(); inc(); // b.js import { get } from './counter.js'; get(); // 2 — same instance, not a fresh copy ```

Module vs Script, live bindings, circular deps, tree-shaking

#
**Module vs Script** | Feature | Script | Module | |---|---|---| | Scope | Global | Module-local | | `this` at top level | `window` | `undefined` | | Strict mode | opt-in | always on | | `import`/`export` | not allowed | allowed | | Execution | once per `<script>` tag | once, then cached | | HTML | `<script>` | `<script type="module">` | | Deferred | no (blocks HTML) | yes (deferred by default) | **Import is a live binding — not a copy** Unlike CommonJS (`require`), ES module imports are live bindings. When the exporting module mutates the value, importers see the update: ```js // counter.js export let count = 0; export function inc() { count++; } // main.js import { count, inc } from './counter.js'; console.log(count); // 0 inc(); console.log(count); // 1 — live binding, not a snapshot // CommonJS (Node.js require) would give 0 again — it copies the value ``` **Circular dependencies** ES modules handle cycles by providing an incomplete binding at first — the value is `undefined` until the module finishes evaluating. This can cause subtle bugs: ``` BAD: a.js --> b.js --> a.js (cycle) GOOD: a.js \ --> shared.js b.js / ``` Rule: if you see a cycle in your bundler output, extract the shared code to a third file. Cycles are a design smell, not a bundler limitation. **Tree-shaking** Bundlers (Vite, webpack, Rollup) can remove unused exports from the final bundle because ES module structure is static and known at build time. This is called *tree-shaking*: ```js // utils.js exports 10 functions export function used() { ... } export function neverImported() { ... } // removed by bundler // main.js import { used } from './utils.js'; // only 'used' ends up in bundle ``` Tree-shaking only works with named exports and static imports. Dynamic `import()` and `export default` (single object with many methods) prevent effective tree-shaking.
01

Named exports and imports

#

Write a module `math.js` that exports two named functions: `add(a, b)` and `multiply(a, b)`. In `main.js`, import both and use them.

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

// complete multiply and export it
function multiply(a, b) {
  return a * b;
}

// main.js
import { add, multiply } from './math.js';
console.log(add(2, 3));       // 5
console.log(multiply(4, 5));  // 20
Solution
// math.js
export function add(a, b) {
  return a + b;
}

export function multiply(a, b) {
  return a * b;
}

// main.js
import { add, multiply } from './math.js';
console.log(add(2, 3));       // 5
console.log(multiply(4, 5));  // 20
02

Default export and import

#

Create a module `greet.js` with a default export — a function `greet(name)` that returns `'Hello, <name>!'`. Import it in `main.js` under any name and call it.

// greet.js
// add default export here
function greet(name) {
  return `Hello, ${name}!`;
}

// main.js
import greet from './greet.js';
console.log(greet('Alice')); // Hello, Alice!

// you can also import under a different name:
import sayHello from './greet.js';
console.log(sayHello('Bob')); // Hello, Bob!
Solution
// greet.js
export default function greet(name) {
  return `Hello, ${name}!`;
}

// main.js
import greet from './greet.js';
console.log(greet('Alice')); // Hello, Alice!

import sayHello from './greet.js';
console.log(sayHello('Bob')); // Hello, Bob!
03

Import aliasing with `as`

#

A module `utils.js` exports `formatDate` and `formatCurrency`. Import them both aliased: `formatDate` as `dateStr`, `formatCurrency` as `money`. Also import the entire module as a namespace object `utils` and use `utils.formatDate` and `utils.formatCurrency`.

// utils.js
export function formatDate(d) {
  return d.toISOString().slice(0, 10);
}
export function formatCurrency(n) {
  return '$' + n.toFixed(2);
}

// main.js - aliased imports:
import { formatDate as dateStr, formatCurrency as money } from './utils.js';
console.log(dateStr(new Date('2024-01-15'))); // 2024-01-15
console.log(money(9.9));                      // $9.90

// namespace import:
import * as utils from './utils.js';
console.log(utils.formatDate(new Date('2024-01-15')));
console.log(utils.formatCurrency(9.9));
Solution
// utils.js
export function formatDate(d) {
  return d.toISOString().slice(0, 10);
}
export function formatCurrency(n) {
  return '$' + n.toFixed(2);
}

// main.js
import { formatDate as dateStr, formatCurrency as money } from './utils.js';
console.log(dateStr(new Date('2024-01-15'))); // 2024-01-15
console.log(money(9.9));                      // $9.90

import * as utils from './utils.js';
console.log(utils.formatDate(new Date('2024-01-15')));
console.log(utils.formatCurrency(9.9));
04

Re-export: barrel index file

#

You have two modules: `circle.js` (exports `area` and `perimeter`) and `rect.js` (exports `area` and `perimeter`). Create an `index.js` that re-exports everything from both, renaming to avoid name collisions. Then import from `index.js` only.

// circle.js
export const area = r => Math.PI * r * r;
export const perimeter = r => 2 * Math.PI * r;

// rect.js
export const area = (w, h) => w * h;
export const perimeter = (w, h) => 2 * (w + h);

// index.js — re-export with renames:
export { area as circleArea, perimeter as circlePerimeter } from './circle.js';
// add rect re-exports here

// main.js
import { circleArea, rectArea } from './index.js';
console.log(circleArea(5).toFixed(2));  // 78.54
console.log(rectArea(3, 4));             // 12
Solution
// circle.js
export const area = r => Math.PI * r * r;
export const perimeter = r => 2 * Math.PI * r;

// rect.js
export const area = (w, h) => w * h;
export const perimeter = (w, h) => 2 * (w + h);

// index.js
export { area as circleArea, perimeter as circlePerimeter } from './circle.js';
export { area as rectArea, perimeter as rectPerimeter } from './rect.js';

// main.js
import { circleArea, rectArea } from './index.js';
console.log(circleArea(5).toFixed(2));  // 78.54
console.log(rectArea(3, 4));             // 12
05

Dynamic import()

#

Use `import()` to load a module conditionally. If `useLargeLib` is `true`, dynamically import `heavy.js` (which exports a default `process` function). Otherwise skip the load. Show how to use the loaded module.

// heavy.js
export default function process(data) {
  return data.toUpperCase();
}

// main.js
async function run(useLargeLib) {
  if (useLargeLib) {
    // dynamically import heavy.js here
    // call the default export with 'hello'
  } else {
    console.log('Skipped heavy module');
  }
}

run(true);
Solution
// heavy.js
export default function process(data) {
  return data.toUpperCase();
}

// main.js
async function run(useLargeLib) {
  if (useLargeLib) {
    const { default: process } = await import('./heavy.js');
    console.log(process('hello')); // HELLO
  } else {
    console.log('Skipped heavy module');
  }
}

run(true);
06

Module as a singleton

#

Create a `config.js` module that exports a single mutable `config` object. Show that importing it in two places gives the same object reference — mutations in one place are visible in the other.

// config.js
export const config = { debug: false, theme: 'light' };

// a.js
import { config } from './config.js';
config.debug = true;  // mutate
console.log('In a.js:', config.debug); // true

// b.js
import { config } from './config.js';
console.log('In b.js:', config.debug); // also true — same object!
Solution
// config.js
export const config = { debug: false, theme: 'light' };

// a.js
import { config } from './config.js';
config.debug = true;
console.log('In a.js:', config.debug); // true

// b.js
import { config } from './config.js';
console.log('In b.js:', config.debug); // true (same module instance)
07

Understand circular dependency risk

#

`a.js` imports from `b.js`, and `b.js` imports from `a.js` — circular. Show what happens: `a.js` exports `A = 'A'`, imports `B` from `b.js`. `b.js` exports `B = 'B'`, imports `A` from `a.js`. In `main.js`, import both and log them. Then rewrite to break the cycle using a shared `constants.js`.

// --- CIRCULAR (problematic) ---
// a.js
import { B } from './b.js';
export const A = 'A';
console.log('In a.js, B =', B);  // may be undefined!

// b.js
import { A } from './a.js';
export const B = 'B';
console.log('In b.js, A =', A);  // may be undefined!

// --- FIX: break the cycle ---
// constants.js
// export A and B from here, no imports
Solution
// CIRCULAR — a.js imports b, b imports a
// When a.js is first evaluated, b.js is not yet done,
// so B may be undefined when a.js runs its top-level code.

// --- FIX ---
// constants.js
export const A = 'A';
export const B = 'B';

// a.js
import { B } from './constants.js';  // no cycle
console.log('In a.js, B =', B);  // 'B' (always defined)

// b.js
import { A } from './constants.js';  // no cycle
console.log('In b.js, A =', A);  // 'A' (always defined)
08

Dynamic import with error handling

#

Write an async function `loadPlugin(name)` that dynamically imports `./plugins/${name}.js` and calls its default-exported `init()` function. If the import fails (e.g. module not found), catch the error and log `'Plugin not found: <name>'`.

async function loadPlugin(name) {
  try {
    // dynamically import the plugin
    // call the init() function
  } catch (err) {
    console.log(`Plugin not found: ${name}`);
  }
}

await loadPlugin('analytics');
await loadPlugin('nonexistent');  // Plugin not found: nonexistent
Solution
async function loadPlugin(name) {
  try {
    const { default: init } = await import(`./plugins/${name}.js`);
    init();
  } catch (err) {
    console.log(`Plugin not found: ${name}`);
  }
}

await loadPlugin('analytics');
await loadPlugin('nonexistent');  // Plugin not found: nonexistent
09

Top-level await in modules

#

In a module `data.js`, use top-level `await` to fetch data before exporting it. Simulate this with a `delay` function. Export the resolved `data` const. In `main.js`, import and use it — no extra await needed.

// data.js
const delay = ms => new Promise(r => setTimeout(r, ms));

await delay(10);  // top-level await
export const data = { loaded: true, items: [1, 2, 3] };

// main.js
import { data } from './data.js';
// by the time this runs, data is already resolved
console.log(data.loaded);  // true
console.log(data.items);   // [1, 2, 3]
Solution
// data.js
const delay = ms => new Promise(r => setTimeout(r, ms));

await delay(10);
export const data = { loaded: true, items: [1, 2, 3] };

// main.js
import { data } from './data.js';
console.log(data.loaded);  // true
console.log(data.items);   // [1, 2, 3]
10

Module vs script: key differences

#

Demonstrate three key differences between ES modules and classic scripts: 1. Module-scoped `this` is `undefined` at top level. 2. Modules have their own scope — variables are not global. 3. `import` declarations are hoisted (you can import at top, use at bottom). Show each with a comment explaining what the output would be.

// module.js (as a module — type='module' in HTML or .mjs in Node)

// 1. this at top level
console.log(typeof this); // 'undefined' (in module)
                          // 'object' (window) in classic script

// 2. module scope — x is NOT global
const x = 42;
console.log(typeof globalThis.x); // 'undefined' in module
                                   // '42' if classic script

// 3. imports are hoisted — you can use them before their textual position
console.log(typeof add);  // 'function' (import is hoisted)
import { add } from './math.js';
Solution
// module.js

// 1. this at top level in a module is undefined
console.log(typeof this); // 'undefined'

// 2. module-scoped const — not attached to globalThis
const x = 42;
console.log(typeof globalThis.x); // 'undefined'

// 3. import declarations are hoisted — safe to reference before textual position
console.log(typeof add);  // 'function'
import { add } from './math.js';