Modules and packages
Assignment
Martina Hostettler sent the catalog. The volunteers exported the spreadsheet as JSON, 60 things in six categories. One of them also wrote a small script that prints an overview.
As a volunteer at the counter, I want to get the overview with one command, so that I don't have to search the spreadsheet.
A note from the volunteer is attached. "Everything is in one file. For the app, the functions should probably be split up." The acceptance criteria are:
- An npm command prints the overview.
- The functions for amounts and the functions for the catalog each live in a module of their own.
- The overview uses these modules and defines no function twice.
Learning goals
After this chapter you can
- split code across several files with
exportandimport, - explain how Node and the browser load ES modules,
- describe a project with
package.json, install packages and run scripts, - read version ranges and explain what the lock file is for.
For this you need the basics from the entry test, especially functions and objects.
One file for everything
Section titled “One file for everything”The volunteer's script reads the export and prints two lines. Read it and think about which parts the app will need later as well.
import { readFileSync } from 'node:fs'
// The export of the volunteers, read as text and turned into objectsconst items = JSON.parse(readFileSync(new URL('../data/items.json', import.meta.url), 'utf8'))
const chf = new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' })
function formatDeposit(amount) { return chf.format(amount)}
function countAvailable(items) { let count = 0 for (const item of items) { if (item.availableFrom === null) count += 1 } return count}
function findById(items, id) { for (const item of items) { if (item.id === id) return item } return undefined}
console.log(`${countAvailable(items)} of ${items.length} things are in.`)
const projector = findById(items, 6)console.log(`${projector.name}, deposit ${formatDeposit(projector.deposit)}`)The script works. But the functions formatDeposit, countAvailable and findById sit in a file that prints something as soon as it starts. The app can't use them without copying the file, and a copy means fixing every bug in two places. The answer is modules. A module is a file that decides what other files may use from it.
Export and import
Section titled “Export and import”With export, a module makes a function, a constant or a class available. Everything else stays inside the module. In the example, format.mjs exports the function formatDeposit, while the formatter chf stays internal.
const chf = new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' })
export function formatDeposit(amount) { return chf.format(amount)}export function countAvailable(items) { let count = 0 for (const item of items) { if (item.availableFrom === null) count += 1 } return count}
export function findById(items, id) { for (const item of items) { if (item.id === id) return item } return undefined}With import, another module gets these names. The curly braces hold the names it needs, and from is followed by the path to the file. A path that starts with ./ or ../ is relative to the file that contains the import.
import { countAvailable, findById } from './catalog.mjs'import { formatDeposit } from './format.mjs'import { items } from './items.mjs'
console.log(`${countAvailable(items)} of ${items.length} things are in.`)
const projector = findById(items, 6)console.log(`${projector.name}, deposit ${formatDeposit(projector.deposit)}`)The examples import each other, so they don't run with the "Run" button but with Node. In the course repo you start them in the folder examples/01-javascript/modules/en.
node main.mjsThe output is 3 of 4 things are in. and Projector, deposit CHF 80.00. A module has a scope of its own. No other module sees the variables it doesn't export, and two modules may use the same names internally without getting in each other's way.
Names when importing
Section titled “Names when importing”You can rename a name when you import it, for example when two modules both offer a function called format. import { nextId as reservationId } from './ids.mjs' gets nextId under the name reservationId. import * as catalog gets all exports of a module as one object.
import * as catalog from './catalog.mjs'import { items } from './items.mjs'
console.log(Object.keys(catalog))console.log(catalog.findById(items, 3).name)node namespace.mjs prints [ 'countAvailable', 'findById' ] and then the name of the raclette oven. Besides named exports there is the default export, export default function formatDeposit(...), which is imported without curly braces, import formatDeposit from './format.mjs'. The course uses named exports, because then the name is the same everywhere and the editor finds it when you rename it. You will see a default export with Vue components. The compiler turns every .vue file into one, which is why you import ItemCard without curly braces.
A module runs once
Section titled “A module runs once”When several files import the same module, JavaScript runs its code only the first time. After that, all of them get the same module with the same variables.
// The module runs once, every file that imports it shares this variablelet next = 1
export function nextId() { return next++}import { nextId } from './ids.mjs'import { nextId as reservationId } from './ids.mjs'
console.log(nextId(), reservationId(), nextId())node shared.mjs prints 1 2 3. Both imports point to the same function and thus to the same variable next. That way a module can hold state for a whole app. In part 7 the course uses this to share data.
Modules in Node and in the browser
Section titled “Modules in Node and in the browser”Node treats a file ending in .mjs as an ES module. A file ending in .js counts as an ES module when the nearest package.json contains "type": "module". Without that entry, current versions of Node detect import and export on their own, but older versions and some tools then expect CommonJS, the older module system with require and module.exports. With the entry the case is clear for everyone, which is why every project of the course has it. You will find CommonJS in older code and in many answers from AI assistants.
In the browser, <script type="module" src="main.js"></script> loads a module. It runs only once the page has been read, and strict mode always applies. Without type="module", the browser reports the error Cannot use import statement outside a module at the first import. The browser loads modules over HTTP, and if you open a page directly as a file, most browsers block its modules. An import such as import { ref } from 'vue' doesn't work in the browser without help either, because the browser doesn't know where the package vue lives. In part 2, Vite takes care of both, a tool that runs as a server during development and bundles all modules for publishing.
npm and package.json
Section titled “npm and package.json”npm installs packages, that is, code written by others, and runs scripts. What a project needs is listed in the file package.json. This is what it looks like in the exercise project of this chapter.
{ "name": "web2-modules-exercise", "version": "1.0.0", "private": true, "type": "module", "scripts": { "test": "vitest run" }, "devDependencies": { "vitest": "5.0.5" }}type sets the module system, as described above. scripts holds named commands. npm run test runs the command vitest run, and for test and start you may leave out the run. devDependencies lists packages that are only needed during development, here Vitest for the tests. Whatever the app itself needs to run, such as Vue, goes into dependencies. private: true keeps anyone from accidentally publishing the project to the public npm registry.
npm install reads the file and downloads all packages into the folder node_modules, including the packages these packages need themselves. You add a new package with npm install -D vitest, where -D puts it under devDependencies. You run tools from node_modules with npx, for example npx vitest.
Versions and the lock file
Section titled “Versions and the lock file”Package versions have three numbers, such as 5.0.5. The first one goes up with changes that can break existing code, the second with new features and the third with bug fixes. This scheme is called semantic versioning. package.json often contains a range instead of a version.
| Entry | Allows |
|---|---|
5.0.5 | exactly this version |
~5.0.5 | bug fixes, that is 5.0.x from 5.0.5 |
^5.0.5 | everything up to but not including 6.0.0 |
The course writes exact versions so that everyone works with the same packages. Even then, the packages themselves have dependencies with ranges. That's why npm install writes the file package-lock.json, which records the exact version of every installed package. The lock file belongs in the repo, node_modules never does. npm ci installs exactly what the lock file says, which suits servers that run tests.
In code
Section titled “In code”Work with the examples in the folder examples/01-javascript/modules/en. Each time, predict what will happen before you run a command.
- What does
node shared.mjsprint when you change the linelet next = 1inids.mjstolet next = 100? - Remove the word
exportin front ofcountAvailableincatalog.mjsand runnode main.mjs. Which message appears, and does it appear before or after the first line of output? - In
main.mjs, leave out the extension.mjswhen importingcatalog.mjs. What does Node report now? - Restore everything and additionally import the formatter
chffromformat.mjsinmain.mjs. Why does that fail?
The messages from steps 2 and 3 appear before the first line runs. JavaScript resolves all imports before it runs the code of a module.
Exercises
Section titled “Exercises”The exercises build on the volunteer's script. You find it in the course repo in the folder exercises/01-javascript/modules/en. Copy the folder, install the packages and run the tests.
npm installnpm testnode src/overview.js shows the overview. At the start almost all tests fail, and that's intended.
Exercise Getting started
Create the file src/format.js. It exports the function formatDeposit, which formats an amount like CHF 20.00. Remove the function from src/overview.js and import it there from the new module. The tests in level-1.test.js check both.
Solution
const chf = new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' })
export function formatDeposit(amount) { return chf.format(amount)}The function disappears from src/overview.js, and the top of the file gets import { formatDeposit } from './format.js'. The formatter chf moves into the module too. It stays there without export, because nobody but formatDeposit needs it.
Exercise Going further
Move countAvailable and findById into a module src/catalog.js and import them in the overview. Then add a script overview to package.json that runs node src/overview.js, and start the overview with npm run overview.
Solution
export function countAvailable(items) { let count = 0 for (const item of items) { if (item.availableFrom === null) count += 1 } return count}
export function findById(items, id) { for (const item of items) { if (item.id === id) return item } return undefined}Under scripts in package.json you add the line "overview": "node src/overview.js". npm runs the command in the folder of the project, so the path is right no matter where you start the script from. The last test runs the overview and checks that it still prints the same after the refactoring.
Exercise Challenge
Later, the app should be able to import all functions from one single place. Create src/index.js, which passes on all functions from catalog.js and format.js without copying them. Change the overview so that it imports from ./index.js only.
Solution
// One place to import from, the functions themselves stay in their modulesexport { countAvailable, findById } from './catalog.js'export { formatDeposit } from './format.js'import { readFileSync } from 'node:fs'
import { countAvailable, findById, formatDeposit } from './index.js'
// The export of the volunteers, read as text and turned into objectsconst items = JSON.parse(readFileSync(new URL('../data/items.json', import.meta.url), 'utf8'))
console.log(`${countAvailable(items)} of ${items.length} things are in.`)
const projector = findById(items, 6)console.log(`${projector.name}, deposit ${formatDeposit(projector.deposit)}`)export { ... } from passes on names of another module without importing them itself. The functions stay in their modules and index.js only points to them, which is why findById from index.js is the same function as the one from catalog.js. With export * from './catalog.js', the module would pass on everything catalog.js exports. The explicit list, on the other hand, shows at a glance what the module offers.
Common mistakes
Section titled “Common mistakes”| Mistake | What helps |
|---|---|
The browser reports Cannot use import statement outside a module. | The <script> tag lacks type="module". |
An import without an extension such as from './catalog', and Node reports ERR_MODULE_NOT_FOUND. | Write the extension in Node and in the browser. Only tools such as Vite add it themselves. |
Node reports does not provide an export named. | Compare the name in the export and in the import, including upper and lower case. |
node_modules ends up in the repo. | Add node_modules/ to the file .gitignore. npm install creates the folder again at any time. |
A test compares formatDeposit(20) with 'CHF 20.00' and fails, even though the output looks the same. | Intl puts a non-breaking space after CHF. Normalize the spaces in the test first, as level-1.test.js does. |
An AI assistant writes require(...) and module.exports. | That is CommonJS, the older module system of Node. The course uses import and export. |
| An AI assistant installs a package for a small task, such as formatting amounts. | Check first whether JavaScript can do it itself, here with Intl.NumberFormat. Every package is code that someone has to maintain. |
Self-check
Section titled “Self-check”Self-check
- What is the difference between a named export and a default export?
- Two files import the same module. How often does its code run, and what does that mean for its variables?
- What is
package-lock.jsonfor, and does it belong in the repo? - What does the entry
^5.0.5inpackage.jsonallow?
Answers
- A named export has a fixed name and is imported with curly braces. A module has at most one default export, which an import without braces gets under any name.
- Only once. All files that import the module share its variables.
- It records the exact version of every installed package, including the packages other packages need, so everyone installs the same. The file belongs in the repo.
- Every version from
5.0.5up to but not including6.0.0, that is new features and bug fixes, but no changes that break existing code.
Assignment
Martina Hostettler thanks you for the overview. A volunteer used it right away to add two new tents, and since then something is wrong. When someone borrows one of the new tents, the other one is gone too. The next chapter is about this bug.
Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.