Skip to main content
ANVISoftware Solutions
Lesson 14 of 20Beginner13 min

Modules

By the end of this lesson

Split code across files with imports and exports.

One file works until it does not. A few hundred lines covering the directory, the expense list and the formatting helpers becomes hard to navigate, and impossible to work on with someone else without stepping on each other.

Modules split that into files that declare what they share. Everything else in a file stays private to it, which is the part that changes how code feels to work with.

expenses.js — a file that exports two things
JavaScript
export const CATEGORIES = ["travel", "equipment", "training"];

// Not exported: nothing outside this file can reach it
function roundToPence(amount) {
  return Math.round(amount * 100) / 100;
}

export function totalFor(expenses, status) {
  const total = expenses
    .filter((expense) => expense.status === status)
    .reduce((runningTotal, expense) => runningTotal + expense.amount, 0);

  return roundToPence(total);
}
  • export in front of a declaration makes it available to other files. Anything without it is private.
  • roundToPence is an implementation detail. Keeping it unexported means you can rename or replace it without checking who else calls it — nobody can.
  • That is the real benefit. The exported names are a deliberate, small surface, and the rest is yours to change.
  • A module can export as many names as it needs. These two are what the rest of the application uses.
directory.js — importing them
JavaScript
import { CATEGORIES, totalFor } from "./expenses.js";
import formatAmount from "./formatAmount.js";

const approvedTotal = totalFor(expenses, "approved");

console.log(CATEGORIES.length);          // 3
console.log(formatAmount(approvedTotal));

// roundToPence is not importable — it was never exported
  • The braces list named exports, and the names have to match what the other file exported. Misspell one and you get an error at load time rather than an undefined value later.
  • The second line imports a default export, which needs no braces because there is only one per file. The local name is yours to choose, which is both convenient and a little risky.
  • The path is relative to the importing file, and in the browser it includes the .js extension. Build tools often let you drop it, so code that works in a bundler can fail in the browser — a specific and confusing failure.
  • A module runs once, the first time it is imported. Import it from five files and the code at the top level still executes a single time, and all five share the same values.
  • Imports are resolved before the file runs, so they belong at the top. Put them there and the file's dependencies are visible in its first few lines.
Loading a module from the page
HTML
<!-- One entry point; it imports everything else it needs -->
<script type="module" src="/scripts/directory.js"></script>
  • type="module" is what allows import to work. Without it the browser treats the file as an ordinary script and the first import line is a syntax error.
  • Module scripts are deferred automatically, so the document has been parsed before the code runs. There is no need for defer, and no risk of a query returning null because the elements did not exist yet.
  • You only load the entry point. Everything it imports is fetched by the browser as needed, so there is no list of script tags to keep in the right order.

Both export styles work. The difference shows up months later, when someone is trying to find every use of something:

 Named exportDefault export
Writing itexport function totalFor() {}export default function totalFor() {}
Importing itimport { totalFor } from "./expenses.js"import totalFor from "./expenses.js"
The local nameMust match, unless you rename with asChosen by the importer, and can differ in every file
Finding every useSearch for the name and you have them allHarder, because the name may not be the same anywhere
TyposFail at load with a clear message about the missing nameCannot fail this way, because any name is accepted
Number per fileAs many as the file needsOne
Reasonable useMost things, most of the timeA file whose whole purpose is one thing, and where the surrounding tooling expects it

What module scope gives you, beyond tidier files:

  • Nothing leaks to the global scope, so two files can both have a helper called format without colliding
  • Dependencies are explicit — the imports at the top are the list of what this file needs
  • Load order is worked out from the imports rather than from the order of script tags
  • Unexported code is genuinely private, so refactoring it is safe by construction
  • Module code runs in strict mode automatically, which turns several silent mistakes into errors

Summary

  • export chooses what a file shares; everything else is private to that file
  • Named exports keep one name across the codebase and make typos fail loudly
  • type="module" is required in the page, and module scripts are deferred by default
  • Browser imports need the file extension, even where build tools do not
  • Module scope means no global leaks, explicit dependencies, and internal code you can change safely

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Split a single file

You have one 300-line file containing: employee filtering, expense totals, currency and date formatting, and the code that renders both lists.

Decide how many modules to split it into, what each one exports, and what stays private. Write the export and import lines only.

Show solution

A reasonable split is by responsibility rather than by type of thing: formatting, expense calculations, employee filtering, and a rendering module that uses the other three. Four files, each with a name that says what it is for.

The useful question for each function is whether anything outside the file needs to call it. A helper that pads a date, or rounds to pence, does not — leave it unexported and you are free to change it.

There is no single right answer, and it is possible to go too far. Twelve modules with one function each means following an import chain through several files to read one feature. Split when a file has more than one reason to change.

JavaScript
// format.js
export function formatAmount(amount) { /* ... */ }
export function formatDate(isoDate) { /* ... */ }
// padTwoDigits stays private to this file

// expenses.js
export const CATEGORIES = ["travel", "equipment", "training"];
export function totalFor(expenses, status) { /* ... */ }
export function groupByCategory(expenses) { /* ... */ }

// employees.js
export function filterByName(employees, term) { /* ... */ }
export function sortByDepartment(employees) { /* ... */ }

// directory.js — the entry point loaded by the page
import { formatAmount, formatDate } from "./format.js";
import { CATEGORIES, totalFor } from "./expenses.js";
import { filterByName } from "./employees.js";

export function startDirectory() { /* ... */ }

Think about it

Think about it

Before modules, every script shared one global scope. Name two specific problems that caused, and say which part of modules addresses each.

Show solution

Name collisions. Two files could each define a function called format or a variable called total, and the one loaded later silently replaced the other. Module scope fixes this: names are local to the file unless exported.

Invisible dependencies. A file used a name defined somewhere else with nothing in the code to say where, so load order mattered and nobody could tell which script tag mattered to which. Imports make that explicit and let the browser resolve the order.

A third, which is really a consequence: nothing was private. Any function could be called from anywhere, so no internal helper could be changed with confidence. Unexported names remove that worry entirely.

Saved in this browser only.