Skip to content
aviral gupta

// I3.1 · ~34 min · Intermediate

import and export: splitting a program into modules

After this lesson you split a program into several files: each file exports what others may use, imports what it needs, and keeps everything else private.

Lesson 1 of 5 in I3 Modules, Node.js and npm

Start of the module

You will be able to

  • Export features from one file and import them by name in another, with a relative path and its extension
  • Explain module scope and live bindings: what an importing file can see, read and not reassign
  • Predict when a module’s code runs: once, before the importing file’s own code
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B4.4 on scope. What does this print?

    {
      const secret = 42;
    }
    console.log(typeof secret);
  2. Predict · Activity 2 of 7

    Predict before you read on. Two files import counter.js. How often does “counter.js runs” appear?

    // counter.js
    console.log("counter.js runs");
    export let count = 0;
    
    // logger.js
    import {count} from "./counter.js";
    export const start = count;
    
    // main.js
    import {count} from "./counter.js";
    import {start} from "./logger.js";
    console.log("main runs");
  3. Practice · Activity 3 of 7

    Fill in the keyword that lets main.js import VAT from prices.js.

    ____ const VAT = 0.19;
    const VAT = 0.19;
  4. Practice · Activity 4 of 7

    Match each piece of module syntax to what it does.

  5. Practice · Activity 5 of 7

    What does main.js print?

    // 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(multiply(add(1, 2), 4));
  6. Brain teaser · Activity 6 of 7

    Brain teaser. The import is written after the console.log. In which order do the lines appear?

    // setup.js
    console.log("setup");
    
    // main.js
    console.log("main");
    import "./setup.js";
  7. Apply · Activity 7 of 7

    Mini-task: split a small shop script into three modules. prices.js keeps the VAT rate private and exports gross(net). cart.js imports gross, keeps the items private and exports addItem(name, net) and total(). main.js adds tea for 4 and cake for 6 and prints the total with VAT. Run it with Node.js from the folder that has a package.json with "type": "module".

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Three modules and a shared counter

prices.js exports withVat and currency and keeps VAT private. counter.js exports count and increment, and logs when it runs. logger.js imports counter.js too. main.js imports from all three. The output shows that counter.js ran once, that main.js cannot see VAT, that count is a live binding which both increment() calls changed, and that main.js cannot assign to it.

main.js

import {withVat, currency} from "./prices.js";
import {count, increment} from "./counter.js";
import {log} from "./logger.js"; // logger.js imports counter.js too

console.log(withVat(10), currency);
console.log(typeof VAT); // VAT belongs to prices.js

increment();
log("hello");
console.log("count:", count); // a live binding: 2

try {
  count = 10;
} catch (error) {
  console.log(error.name + ":", error.message);
}

prices.js

const VAT = 0.19; // not exported: private to this file

export function withVat(net) {
  return Math.round(net * (1 + VAT) * 100) / 100;
}

export const currency = "EUR";

counter.js

console.log("counter.js runs");

export let count = 0;

export function increment() {
  count += 1;
}

logger.js

import {increment} from "./counter.js";

export function log(message) {
  increment();
  console.log("log:", message);
}

Run it with

node main.js

Output

counter.js runs
11.9 EUR
undefined
log: hello
count: 2
TypeError: Assignment to constant variable.
  • counter.js runs printed first, once: imports run before main.js’s code, and the second import, from logger.js, reused the module.
  • count is 2 although main.js never assigned it: increment() changed the variable in counter.js, and the import is a live view of it.
  • count = 10 failed: an importer can read an imported binding but never reassign it.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Exercises

Exercise 1 of 2

Export only the public functions

main.js is a module that other files will import. It should export exactly two functions: slugify(title), which turns "Hello World!" into "hello-world", and isSlug(text). The helper clean(text) is only used inside the file and must stay private. The starter exports clean too. Remove that export, so the module’s public API is just the two functions.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Run the tests: the module exports three names. Which one is only a helper?

  2. Hint 2

    A function without export is still usable inside its own file.

  3. Hint 3

    Delete the word export in front of function clean.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

function clean(text) {
  return text.toLowerCase().replace(/[^a-z0-9 ]/g, "").trim();
}

export function slugify(title) {
  return clean(title).split(/ +/).join("-");
}

export function isSlug(text) {
  return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(text);
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export function clean(text) {
  return text.toLowerCase().replace(/[^a-z0-9 ]/g, "").trim();
}

export function slugify(title) {
  return clean(title).split(/ +/).join("-");
}

export function isSlug(text) {
  return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(text);
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import * as slugs from './main.js';

test('The module exports exactly slugify and isSlug', () => {
  const names = Object.keys(slugs).sort();
  assert.deepEqual(names, ['isSlug', 'slugify'], `main.js exports ${JSON.stringify(names)}`);
});

test('slugify turns a title into a slug', () => {
  const got = slugs.slugify('Hello  World!');
  assert.equal(got, 'hello-world', `slugify gave ${got}`);
});

test('isSlug accepts a slug and rejects a title', () => {
  assert.equal(slugs.isSlug('hello-world'), true, 'hello-world is a slug');
  assert.equal(slugs.isSlug('Hello World'), false, 'Hello World is not a slug');
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 2 of 2

A live count of the items

main.js is a small store module. It should export addItem(name) and count, the number of items so far, so that an importer sees count change after each addItem. The starter exports count as items.length, worked out once when the module ran, so it stays 0. Make count a let that addItem updates.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Run the tests: count stays 0. When is items.length read in the starter?

  2. Hint 2

    An imported let is a live binding: when main.js assigns a new value, importers see it.

  3. Hint 3

    export let count = 0; and in addItem: count = items.length;

Show a solution

One way to solve it. Yours can look different and still pass the checks.

const items = [];
export let count = 0;

export function addItem(name) {
  items.push(name);
  count = items.length;
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

const items = [];

export function addItem(name) {
  items.push(name);
}

export const count = items.length;

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {addItem, count} from './main.js';
import * as store from './main.js';

test('count starts at 0', () => {
  assert.equal(store.count, 0, `count is ${store.count} before any item`);
});

test('The imported count follows addItem', () => {
  addItem('tea');
  addItem('cake');
  assert.equal(count, 2, `after two items the imported count is ${count}`);
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Common mistakes

Importing a name the module does not export

import {sum} from "./math.js";
console.log(sum(1, 2));

What Node.js prints

SyntaxError: The requested module './math.js' does not provide an export named 'sum'

Why, and the fix

The names in the braces must be exactly the exported names. math.js exports add, not sum, so the module cannot be linked and nothing runs. Import add, or rename while importing: import {add as sum} from "./math.js". The error is a SyntaxError although the syntax is fine: it happens while the modules are linked, before any code runs.

Assigning to an imported binding

import {count} from "./counter.js";
count = count + 1;
console.log(count);

What Node.js prints

TypeError: Assignment to constant variable.

Why, and the fix

An import is read-only in the importing file, even if the exporting module declared it with let. Only counter.js can change count. Export a function that does it, such as export function increment() { count += 1; }, and call that.

Leaving out the file extension

import {add} from "./math";
console.log(add(1, 2));

What Node.js prints

Error [ERR_MODULE_NOT_FOUND]: Cannot find module

Why, and the fix

Node.js does not try extensions for an ES module path: "./math" looks for a file called exactly math, which does not exist. A browser would request the URL ./math and fail the same way. Write the full file name: "./math.js".

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

One file, one module

In a module, each file has its own top-level scope. A const or function there is private to that file, unless you put export in front of it: export function add(a, b) { … }. Another file brings it in with import {add} from "./math.js". The names in the braces must match the exported names. The path starts with ./ or ../ and includes the extension .js: browsers and Node.js do not guess it. export works only at the top level of a file, never inside a function, and modules run in strict mode automatically.

Imports are live, read-only bindings

An import is not a copy. It is a live binding to the exporting module’s variable: if that module changes the value, for example count += 1 inside its own increment(), every importer sees the new value. The importer cannot assign to it: count = 10 in main.js throws TypeError: Assignment to constant variable. An imported object can still be changed through its properties, and every importer sees that change too, because they all share the one object.

A module runs once

The first time a module is imported, its top-level code runs. Every later import, from any file, gets the same module, already evaluated, so its variables are shared state, not fresh copies. Import declarations are hoisted: the imported modules run before the importing file’s own code, whatever line the import is written on. A module that should only run, such as setup code, can be imported for its side effects: import "./setup.js";.

Sources

Last reviewed October 5, 2026