At the close of the previous lesson the Nómada Tasks model was complete and protected: Task with its private state, Board with its minimal public API, ValidationError with its hierarchy. The problem is that all of it lives in a single js/app.js that is already past three hundred lines, mixed together with the constants, the sample backlog and the code that runs it. Splitting it into several files and loading them with <script> solves nothing: they all share the same global space, load order becomes an invisible dependency nobody has written down, and it only takes two files declaring const WEIGHTS for the application to fall over. In this lesson you will learn JavaScript's module system: explicit boundaries between files, with export to declare what is on offer and import to declare what is needed. By the end you will have reorganized the whole project into six real files, with a dependency graph you can draw.
Contents
- The problem: global scope, order and invisible dependencies
export: named exportsimport: bringing in what you need- Renaming with
asand importing everything with* as export default- Re-exporting: the barrel file
- Own scope and single execution
<script type="module">and what changes- Dynamic
import()and module-levelawait - CommonJS:
requireandmodule.exports - Node and
"type": "module" - Circular dependencies
- Nómada Tasks, reorganized
- Common Mistakes and Tips
- Exercises
- Conclusion
- The problem: global scope, order and invisible dependencies
Suppose you split the big file into three and load them as you learned in Module 1:
<script src="js/constants.js"></script>
<script src="js/task.js"></script>
<script src="js/app.js"></script>This has three serious problems, and none of them gives a clear error message.
Problem 1: everything shares the same global scope. Every classic <script> runs its code in the global scope, so all top-level declarations live together in the same namespace.
// js/constants.js
const WEIGHTS = { high: 3, medium: 2, low: 1 };
// js/format.js
const WEIGHTS = { bold: 700, normal: 400 };
// ✗ SyntaxError: Identifier 'WEIGHTS' has already been declaredTwo files written by two different people, each one perfectly reasonable, and the application does not start. With var it would be even worse: there would be no error, the second would overwrite the first silently, and the effort calculation would come out NaN somewhere unexpected.
Problem 2: order matters and it is written nowhere. If somebody moves the <script> for task.js above the one for constants.js, the Task class will try to use WEIGHTS before it exists. The HTML becomes a critical configuration file nobody documents.
Problem 3: dependencies are invisible. Open task.js and tell me what it depends on. You cannot know without reading the whole thing and hunting for the identifiers it does not declare. There is no line saying "this file needs WEIGHTS and ValidationError".
The classic workaround, before modules existed, was the module pattern from 03-04: wrapping everything in a self-invoking function and hanging a single variable off the global object.
var NomadaTasks = (function () {
const WEIGHTS = { high: 3, medium: 2, low: 1 }; // private thanks to the closure
class Task { /* … */ }
return { Task }; // the only public thing
})();It works —it was best practice for years— but it still does not solve ordering or make dependencies explicit, and it only takes two libraries picking the same global name to be back at problem 1. Since ES2015, the language has shipped the solution.
export: named exports
export: named exportsA module is simply a .js file that uses export or import. Everything it declares is private to that file except what it exports explicitly.
// util/dates.js
export const TODAY = '2026-09-20';
export function daysBetween(from, to) {
const ms = new Date(to) - new Date(from);
return Math.round(ms / (1000 * 60 * 60 * 24));
}
export function isOverdue(dueDate, status, today = TODAY) {
return dueDate < today && status !== 'done'; // R10
}
// This one is NOT exported: it is an internal detail, invisible from outside
function normalizeDate(text) {
return new Date(text).toISOString().slice(0, 10);
}There are two ways of writing the same thing. The first is putting export in front of each declaration, as above. The second is grouping them at the end of the file, which gives you a very convenient "table of contents":
// util/dates.js — variant with an export list
const TODAY = '2026-09-20';
function daysBetween(from, to) { /* … */ }
function isOverdue(dueDate, status, today = TODAY) { /* … */ }
function normalizeDate(text) { /* … */ }
export { TODAY, daysBetween, isOverdue }; // normalizeDate stays insideAnything with a name can be exported: const, let, function, class. And it can be renamed in the export itself:
exportandimportdeclarations must be at the top level of the module, never inside anif, a loop or a function. The reason is that they are analyzed before anything runs: that lets the browser discover the file graph and download the files in parallel, and it is also what makes it possible to detect import errors without ever executing the program. For conditional importing there is dynamicimport()(section 9).
import: bringing in what you need
import: bringing in what you needAt the other end, import declares what is needed and where it comes from.
// model/task.js
import { TODAY, isOverdue } from '../util/dates.js';
export class Task {
// …
isOverdue(today = TODAY) {
return isOverdue(this.dueDate, this.status, today);
}
}The braces of import { … } are not destructuring, even though they look a lot like the ones in 04-07. It is a syntax of its own belonging to the module system: the names have to match the exported ones exactly, it takes no default values or nested patterns, and it is resolved before anything runs.
About the module path (the specifier), three rules that trip people up in the browser:
| Specifier | Meaning | Valid in the browser? |
|---|---|---|
'./dates.js' |
Relative to this file, same folder | Yes |
'../util/dates.js' |
Relative, going up one level | Yes |
'/js/util/dates.js' |
Absolute from the site root | Yes |
'dates.js' |
Bare specifier (no ./) |
No: the browser reads it as a package name |
And the most important one: in the browser, the .js extension is mandatory. import { TODAY } from './util/dates' fails with a 404. Node in ESM mode requires it too. It is the number one mistake for people coming from other environments where it can be omitted.
- Renaming with
as and importing everything with * as
as and importing everything with * asIf two modules export something with the same name, or if the original name clashes with a local variable, you rename it on import:
import { isOverdue as dateOverdue } from '../util/dates.js';
import { isOverdue as taskOverdue } from './rules.js';
console.log(dateOverdue('2026-09-05', 'pending')); // trueAnd if you want the whole module grouped under one name:
import * as dates from '../util/dates.js';
console.log(dates.TODAY); // '2026-09-20'
console.log(dates.daysBetween('2026-09-20', '2026-09-30')); // 10
console.log(dates.normalizeDate); // undefined ← it was not exportedThat dates is the module namespace object: it holds one property per export. It has two peculiarities: it is frozen (you cannot add to it or change it) and its properties are live bindings, not copies, something you will see in section 7.
| Form | When to use it |
|---|---|
import { a, b } from '…' |
The normal case: you see at a glance what is used |
import { a as x } from '…' |
There is a name clash or the original is unclear here |
import * as ns from '…' |
The module exports many related things and the prefix adds clarity (dates.daysBetween) |
import '…' |
Only the effect of running it matters (registering something, loading styles); no names are brought in |
export default
export defaultEach module can have one default export, meant for when the file represents a single thing.
// app.js
import Board from './model/board.js'; // no braces, and you choose the name
import TheWorkshopBoard from './model/board.js'; // also valid: it is the same thingThe difference from named exports:
| Named | Default | |
|---|---|---|
| How many per module | As many as you like | One |
| Import syntax | import { X } from … |
import X from … |
| Must the name match? | Yes | No, the importer chooses it |
| Typos | Caught at load time | Go unnoticed |
| Editor autocompletion | Works well | Worse |
They can be combined in the same file:
// model/task.js
export default class Task { /* … */ }
export class RecurringTask extends Task { /* … */ }
export const STATUSES = ['pending', 'in-progress', 'done'];In this project we will use named exports in every file. It is not the only valid option —many teams use default for each module's main piece— but named exports have two practical advantages: the name is the same across the whole code base, which means searching for Board finds every use, and a typo shows up instantly instead of giving you undefined halfway through execution.
- Re-exporting: the barrel file
A module can re-export what it imports from others, without ever using it. It serves to offer a single entry point to a group of files.
// model/index.js — a "barrel"
export { Task, RecurringTask } from './task.js';
export { Board } from './board.js';
export { ValidationError, DataError } from './errors.js';
// You can also re-export everything from a module:
export * from './rules.js';
// Or re-export a default under a name:
export { default as Formatter } from './format.js';Whoever consumes it sees a single module:
Barrels are convenient, but an honest warning is in order: importing from the barrel drags in the load of every file it re-exports, even if you only use one. With six files it is irrelevant; in large projects it is one of the typical reasons startup becomes slow, and its relationship with code splitting is covered in 09-05. For a project the size of Nómada Tasks, importing directly from each file is clearer.
- Own scope and single execution
Two properties of modules that solve exactly the problems in section 1.
Every module has its own scope. Nothing you declare is global, so two modules can declare const WEIGHTS without either one noticing. Problem 1 is gone.
// util/format.js
const WEIGHTS = { bold: 700, normal: 400 }; // ✓ no conflict with the one in rules.jsA module runs only once, however many times it is imported. The first import loads and runs it; the rest receive the already-computed result. That makes every module a de facto singleton:
// data/counter.js
console.log('⚙ counter.js is running');
export const log = [];
export function record(text) { log.push(text); }// model/board.js
import { record } from '../data/counter.js';
record('board loaded');
// model/task.js
import { record, log } from '../data/counter.js';
record('task loaded');
console.log(log); // [ 'board loaded', 'task loaded' ] ← the SAME arrayIn the console, '⚙ counter.js is running' appears only once. The two modules share the same log because they share the same module instance. That property is extremely useful —a configuration module, a cache, a shared store— but it is worth keeping in mind: any state you put at module level is global to the whole application, with all the drawbacks that has for the tests in Module 8.
And a fine detail: imports are live bindings, not copies. If the source module reassigns an exported variable, whoever imported it sees the new value.
// app.js
import { calls, increment } from './counter.js';
console.log(calls); // 0
increment();
console.log(calls); // 1 ← it updated by itself
// calls = 5; // ✗ TypeError: Assignment to constant variableImported variables are read-only from the importing module: only the owning module can change them. It is a form of encapsulation that fits perfectly with what you learned in 05-03.
<script type="module"> and what changes
<script type="module"> and what changesFor the browser to treat a file as a module you have to say so:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Nómada Tasks</title>
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<h1>Nómada Tasks · Taller Nómada</h1>
<!-- ONE single script: the entry point. The imports bring in the rest. -->
<script type="module" src="js/app.js"></script>
</body>
</html>A single <script> for the whole application: the imports take care of the rest, and the browser discovers and downloads the complete graph. These are the differences from a classic script:
| Aspect | Classic <script> |
<script type="module"> |
|---|---|---|
| Scope of declarations | Shared global | The module's own |
| Strict mode | Only with 'use strict' |
Always on |
| Execution timing | Blocks the HTML while downloading and running | Implicit defer: waits for the HTML to be ready |
| Executions if included twice | Two | One |
import/export |
SyntaxError |
Allowed |
this at top level |
window |
undefined |
| File origin | Anything, including file:// |
Requires HTTP and respects CORS |
The implicit defer is good practical news: when you get to Module 6 and start looking for elements on the page, the HTML will already be fully built, with no need to wait for any event. And if you need the opposite —running as soon as it downloads, without waiting— there is <script type="module" async src="…">.
The last row is the one that causes the most headaches. Opening index.html by double-clicking does not work with modules:
Access to script at 'file:///…/js/app.js' from origin 'null' has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: http, https, …
The reason is security: modules are downloaded under the same rules as any network request, and the file:// protocol has no valid origin. The solution is the one you already met in 01-02: start a local server.
# With the VS Code Live Server extension: right-click → "Open with Live Server"
# With Python, if you have it installed
python3 -m http.server 8000
# With Node
npx serveAnd then open http://localhost:8000, not the file path. From now on, all development on the project is done with a local server running.
- Dynamic
import() and module-level await
import() and module-level awaitStatic imports go at the top of the file and are resolved before anything runs. When you need to load something conditionally or at a specific moment, there is the function form:
// import() looks like a function call and returns a promise
const mod = await import('./reports/pdf-exporter.js');
mod.exportBoard(board);We focus here only on the syntax, because its main use —splitting the application into chunks that download on demand so it starts faster— belongs to 09-05, and because what it returns is a promise, an object you will study in depth in 05-06. Keep three differences in mind:
Static import |
Dynamic import() |
|
|---|---|---|
| Where it can be written | Only at the top level | Anywhere: inside an if, inside a function… |
| When it is resolved | Before the module runs | At the moment the line executes |
| The path can be a variable | No, it must be a literal | Yes: import(\./locales/${code}.js`)` |
| What it returns | Nothing (it declares bindings) | A promise with the namespace object |
That await in the example is also unusual outside a function: in ES modules you are allowed to use await directly at the top level, something called top-level await.
// data/config.js
const response = await loadSimulatedConfig(); // ✓ only valid in an ES module
export const config = response;When a module uses await at the top level, every module that imports it waits for it to finish before running. It is a convenient tool, but it has a real cost at application startup, so it is worth using with care. You will come back to it in 05-06, when await stops being a word that appears in passing.
- CommonJS:
require and module.exports
require and module.exportsBefore ES modules existed, Node.js invented its own system, called CommonJS, and it is still everywhere. You are going to run into it constantly, so you need to recognize it.
// util/dates.js — CommonJS version
const TODAY = '2026-09-20';
function daysBetween(from, to) {
return Math.round((new Date(to) - new Date(from)) / 86400000);
}
module.exports = { TODAY, daysBetween };
// or: exports.TODAY = TODAY; exports.daysBetween = daysBetween;// app.js — CommonJS version
const { TODAY, daysBetween } = require('./util/dates.js');
const dates = require('./util/dates.js'); // the whole object
console.log(daysBetween(TODAY, '2026-09-30')); // 10Here the braces really are destructuring (04-07), because require returns an ordinary object. That is the underlying difference between the two systems:
ESM (import/export) |
CommonJS (require/module.exports) |
|
|---|---|---|
| Standard | The language's, since ES2015 | Node.js's |
| When it is resolved | Statically, before running | At run time, when the line is reached |
| Loading | Asynchronous | Synchronous (blocking) |
| Where it can be written | Only at the top level | Anywhere, including inside an if |
| Variable path | No | Yes |
| What is imported | Live bindings | A copy of the object at that instant |
| Works in the browser | Yes | Not without tooling |
await at the top level |
Yes | No |
.js extension mandatory |
Yes | No |
The difference between "live binding" and "copy" has visible consequences:
// counter.cjs
let calls = 0;
function increment() { calls += 1; }
module.exports = { calls, increment };const { calls, increment } = require('./counter.cjs');
increment();
console.log(calls); // 0 ← a copy of the value at import time!With ESM, that same code would print 1. It is a classic source of confusion when migrating between systems.
Which to use today: ESM for everything new. It is the language standard, it works in the browser and in Node, and it allows the static analysis the tools in 09-05 depend on. You will see CommonJS in projects a few years old, in many configuration files and in old dependencies.
- Node and
"type": "module"
"type": "module"In the browser, type="module" on the tag decides the system. In Node it is decided by the folder's package.json:
{
"name": "nomada-tasks",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node js/app.js"
}
}With "type": "module", Node reads every .js in that project as an ES module. Without that line (or with "type": "commonjs"), it reads them as CommonJS. And if you need to mix, extensions win over the package.json:
| Extension | Read as |
|---|---|
.js |
Whatever "type" says in the nearest package.json |
.mjs |
Always an ES module |
.cjs |
Always CommonJS |
With that in place you can already run the project from the terminal, which is very handy for testing the logic without opening a browser:
A frequent gotcha: in an ES module there is no require, __dirname or __filename. If you need them there are modern equivalents, but normally you will not miss them.
- Circular dependencies
Two modules that import each other form a cycle. It is not a language error, but it usually ends in undefined at the worst possible moment.
// model/task.js
import { Board } from './board.js';
export class Task {
moveTo(destination) { return new Board(destination); }
}
// model/board.js
import { Task } from './task.js';
export class Board {
add(data) { return new Task(data); }
}What happens underneath is that one module starts running, hits the import, goes to the other, and that one comes back to the first —which has not finished running yet. With class, which has a temporal dead zone (05-02), the typical result is an error like Cannot access 'Task' before initialization.
flowchart LR
A["task.js"] -->|"import { Board }"| B["board.js"]
B -->|"import { Task }"| A
A -.->|"⚠ cycle"| A
A cycle is almost always a design signal: two modules that need each other are really one, or they are missing a third. The three usual ways out:
- Extract what is common into a third module. If both need the constants and the errors, pull them out into
model/errors.jsandmodel/rules.js, and have both depend on those. - Invert the dependency. Let
Boardknow aboutTask(it adds tasks), but letTasknot know aboutBoard. If a task needs something from the board, it should be passed in as a parameter. - Merge the two modules if they really are inseparable.
In Nómada Tasks we apply number 2, which is the one that produces a clean graph: the lower layers do not know about the upper ones.
- Nómada Tasks, reorganized
Here is the whole project spread across its final modules. This is the structure every following module of the course will work on.
nomada-tasks/
├── index.html
├── package.json ← { "type": "module" }
├── css/
│ └── styles.css
└── js/
├── app.js ← entry point
├── model/
│ ├── task.js ← class Task, RecurringTask
│ ├── board.js ← class Board
│ └── errors.js ← ValidationError, DataError
├── data/
│ └── backlog.js ← the canonical backlog
└── util/
├── dates.js ← TODAY, daysBetween, isOverdue
└── format.js ← badges, plurals, text tablesAnd its dependency graph:
flowchart TD
APP["js/app.js<br/>entry point"]
BOA["model/board.js"]
TAS["model/task.js"]
BAC["data/backlog.js"]
ERR["model/errors.js"]
DAT["util/dates.js"]
FOR["util/format.js"]
APP --> BOA
APP --> BAC
APP --> FOR
APP --> ERR
BOA --> TAS
BOA --> ERR
TAS --> ERR
TAS --> DAT
TAS --> FOR
BAC --> TAS
Notice that every arrow points downwards: util/ and model/errors.js depend on nobody, model/task.js depends on them, model/board.js depends on the task, and app.js sits right at the top. There are no cycles, and every file declares in its first lines exactly what it depends on.
Now the files. First the leaves of the graph:
// js/util/dates.js
export const TODAY = '2026-09-20';
/** Days of difference between two ISO dates (negative if 'to' has already passed). */
export function daysBetween(from, to) {
return Math.round((new Date(to) - new Date(from)) / 86400000);
}
/** R10: overdue = due date in the past and the task unfinished. */
export function isOverdue(dueDate, status, today = TODAY) {
return dueDate < today && status !== 'done';
}
/** '2026-09-05' → '5 September 2026' */
export function readableDate(iso) {
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
const [year, month, day] = iso.split('-');
return `${Number(day)} ${MONTHS[Number(month) - 1]} ${year}`;
}// js/util/format.js
export const BADGES = Object.freeze({ pending: '○', 'in-progress': '▸', done: '✓' });
export const WEIGHTS = Object.freeze({ high: 3, medium: 2, low: 1 });
export function statusBadge(status) {
return BADGES[status] ?? '?';
}
export function plural(count, singular, pluralForm) {
return `${count} ${count === 1 ? singular : pluralForm}`;
}
/** Right-aligns a piece of text with spaces, for console tables. */
export function alignRight(text, width) {
return String(text).padStart(width, ' ');
}// js/model/errors.js
export class ValidationError extends Error {
constructor(message, field, receivedValue) {
super(message);
this.name = 'ValidationError';
this.field = field;
this.receivedValue = receivedValue;
}
describe() {
return `[${this.name}] ${this.field}: ${this.message}`;
}
}
export class DataError extends Error {
constructor(message, cause) {
super(message);
this.name = 'DataError';
this.cause = cause;
}
}Now the model, which contains only what belongs to it:
// js/model/task.js
import { TODAY, isOverdue as dateOverdue } from '../util/dates.js';
import { WEIGHTS, statusBadge } from '../util/format.js';
import { ValidationError } from './errors.js';
const TRANSITIONS = Object.freeze({ // R6
pending: ['in-progress'],
'in-progress': ['pending', 'done'],
done: []
});
export class Task {
#status = 'pending'; // R5
#hours;
constructor(data) {
if (typeof data.title !== 'string' || data.title.trim() === '') {
throw new ValidationError('The title cannot be empty.', 'title', data.title); // R2
}
this.id = data.id;
this.title = data.title.trim();
this.assignee = data.assignee || null; // R8
this.priority = data.priority ?? 'medium';
this.tags = [...new Set((data.tags ?? []).map((tag) => tag.trim().toLowerCase()))]; // R9
this.dueDate = data.dueDate;
this.reviewer = data.reviewer ?? null;
this.estimatedHours = data.estimatedHours; // goes through the setter (R3)
if (data.status !== undefined) {
if (!Object.hasOwn(TRANSITIONS, data.status)) {
throw new ValidationError(`Unknown status: "${data.status}".`, 'status', data.status);
}
this.#status = data.status;
}
}
get status() { return this.#status; }
get estimatedHours() { return this.#hours; }
get isOpen() { return this.#status !== 'done'; }
get effort() { return (WEIGHTS[this.priority] ?? 0) * this.#hours; }
set estimatedHours(value) {
if (typeof value !== 'number' || !(value > 0 && value <= 40)) {
throw new ValidationError('Hours must be between 1 and 40.', 'estimatedHours', value); // R3
}
this.#hours = value;
}
isOverdue(today = TODAY) {
return dateOverdue(this.dueDate, this.#status, today);
}
changeStatus(next) {
if (!(TRANSITIONS[this.#status] ?? []).includes(next)) {
throw new ValidationError(
`Transition not allowed: "${this.#status}" → "${next}".`, 'status', next); // R6
}
this.#status = next;
return this;
}
describe(today = TODAY) {
const warning = this.isOverdue(today) ? ' ⚠ OVERDUE' : '';
return `${statusBadge(this.#status)} [${this.id}] ${this.title} · ${this.assignee ?? 'unassigned'} · ${this.#hours} h${warning}`;
}
toJSON() {
return { id: this.id, title: this.title, assignee: this.assignee,
priority: this.priority, status: this.#status, tags: [...this.tags],
estimatedHours: this.#hours, dueDate: this.dueDate, reviewer: this.reviewer };
}
static fromJSON(data) {
return new Task(typeof data === 'string' ? JSON.parse(data) : data);
}
}
export class RecurringTask extends Task {
constructor(data) {
super(data);
this.frequency = data.frequency ?? 'weekly';
}
describe(today) {
return `${super.describe(today)} · repeats ${this.frequency}`;
}
}Notice the import { isOverdue as dateOverdue }: the utility function and the class method have the same name, and as resolves the clash without renaming either of them. That is exactly what it exists for.
// js/model/board.js
import { Task } from './task.js';
import { ValidationError } from './errors.js';
export class Board {
#tasks = [];
constructor(name, tasks = []) {
this.name = name;
for (const t of tasks) this.add(t);
}
get total() { return this.#tasks.length; }
get tasks() { return [...this.#tasks]; }
get open() { return this.#tasks.filter((t) => t.isOpen); }
get totalHours() { return this.#tasks.reduce((s, t) => s + t.estimatedHours, 0); }
get openHours() { return this.open.reduce((s, t) => s + t.estimatedHours, 0); }
get effort() { return this.#tasks.reduce((s, t) => s + t.effort, 0); }
findById(id) { return this.#tasks.find((t) => t.id === id) ?? null; }
filter(predicate) { return this.#tasks.filter(predicate); }
overdue(today) { return this.open.filter((t) => t.isOverdue(today)); }
hoursByAssignee() {
return this.open.reduce((acc, t) => {
const key = t.assignee ?? 'unassigned';
acc[key] = (acc[key] ?? 0) + t.estimatedHours;
return acc;
}, {});
}
add(task) {
if (!(task instanceof Task)) {
throw new ValidationError('Only instances of Task are accepted.', 'task', task);
}
if (this.findById(task.id)) {
throw new ValidationError(`Duplicate id: ${task.id}.`, 'id', task.id); // R1
}
this.#tasks.push(task);
return this;
}
changeStatus(id, next) {
const task = this.findById(id);
if (task === null) throw new ValidationError(`Task ${id} does not exist.`, 'id', id);
task.changeStatus(next);
return this;
}
summary(today) {
return { total: this.total, open: this.open.length,
totalHours: this.totalHours, openHours: this.openHours,
overdue: this.overdue(today).length, effort: this.effort };
}
}// js/data/backlog.js
import { Task } from '../model/task.js';
/** Plain data for the canonical Taller Nómada backlog. */
export const backlogData = [
{ id: 1, title: 'Redesign the multipurpose room', assignee: 'Iván', priority: 'high', status: 'in-progress', tags: ['space', 'design'], estimatedHours: 12, dueDate: '2026-09-30', reviewer: 'Marta' },
{ id: 2, title: 'Signage for the screen-printing workshop', assignee: 'Marta', priority: 'medium', status: 'pending', tags: ['screen-printing', 'communication'], estimatedHours: 6, dueDate: '2026-10-15', reviewer: null },
{ id: 3, title: 'Update the bookings website', assignee: 'Lucía', priority: 'high', status: 'pending', tags: ['web', 'bookings'], estimatedHours: 14, dueDate: '2026-10-02', reviewer: 'Iván' },
{ id: 4, title: 'Screen-printing ink inventory', assignee: 'Marta', priority: 'low', status: 'done', tags: ['screen-printing', 'storeroom'], estimatedHours: 3, dueDate: '2026-09-12', reviewer: null },
{ id: 5, title: 'Bookbinding guide for residents', assignee: 'Iván', priority: 'medium', status: 'in-progress', tags: ['bookbinding', 'documentation'], estimatedHours: 8, dueDate: '2026-11-05', reviewer: 'Lucía' },
{ id: 6, title: 'Carpentry workshop quote', assignee: 'Iván', priority: 'high', status: 'pending', tags: ['carpentry', 'purchasing'], estimatedHours: 5, dueDate: '2026-09-05', reviewer: 'Marta' }
];
/** Returns new instances every time: nobody shares state by accident. */
export function createBacklog() {
return backlogData.map((data) => new Task(data));
}That createBacklog() as a function, instead of an already-built exported array, is a deliberate decision. Since modules run only once (section 7), a directly exported array would be the same one for the whole application, and a test that changed a task's status would contaminate the next one. By returning new instances on every call, each consumer has its own.
And finally the entry point, which no longer contains any logic: it just orchestrates.
// js/app.js
import { Board } from './model/board.js';
import { createBacklog } from './data/backlog.js';
import { TODAY, readableDate } from './util/dates.js';
import { plural, alignRight } from './util/format.js';
import { ValidationError } from './model/errors.js';
const board = new Board('Taller Nómada', createBacklog());
console.log(`— ${board.name} · ${readableDate(TODAY)} —`);
for (const task of board.tasks) {
console.log(task.describe(TODAY));
}
const s = board.summary(TODAY);
console.log(`\n${plural(s.total, 'task', 'tasks')}, ${s.open} open`);
console.log(`Hours: ${s.openHours} open out of ${s.totalHours} total`);
console.log(`Overdue: ${s.overdue} · Weighted effort: ${s.effort}`);
console.log('\nWorkload by assignee:');
for (const [person, hours] of Object.entries(board.hoursByAssignee())) {
console.log(` ${person.padEnd(8)} ${alignRight(hours, 3)} h`);
}
try {
board.changeStatus(6, 'done'); // pending → done: forbidden (R6)
} catch (error) {
if (error instanceof ValidationError) console.error(`\n⚠ ${error.describe()}`);
else throw error;
}Running it with node js/app.js (or opening index.html from the local server and looking at the console):
— Taller Nómada · 20 September 2026 — ▸ [1] Redesign the multipurpose room · Iván · 12 h ○ [2] Signage for the screen-printing workshop · Marta · 6 h ○ [3] Update the bookings website · Lucía · 14 h ✓ [4] Screen-printing ink inventory · Marta · 3 h ▸ [5] Bookbinding guide for residents · Iván · 8 h ○ [6] Carpentry workshop quote · Iván · 5 h ⚠ OVERDUE 6 tasks, 5 open Hours: 45 open out of 48 total Overdue: 1 · Weighted effort: 124 Workload by assignee: Iván 25 h Marta 6 h Lucía 14 h ⚠ [ValidationError] status: Transition not allowed: "pending" → "done".
The same canonical numbers as always, now produced by six files with clear boundaries. And most importantly: if tomorrow the effort calculation has to change, you know exactly which file to open.
Common Mistakes and Tips
- Forgetting the
.jsextension in the specifier.from './util/dates'gives a 404 in the browser and anERR_MODULE_NOT_FOUNDin Node. The extension is mandatory. - Opening the HTML by double-clicking. Modules do not work over
file://because of CORS. Always a local server. - Confusing
import { X }with destructuring. It takes no defaults, no renaming with:, no nested patterns; renaming is done withas. - A misspelled name in a named import. It gives
SyntaxError: The requested module does not provide an export named 'Boar'. It is a good error: it shows up at load time, not halfway through execution. - Mixing
export defaultand named exports without a criterion. Pick a convention per project and stick to it. In Nómada Tasks: always named. - Putting an
importinside anifor a function. It is aSyntaxError; that is what dynamicimport()is for. - Mutable state at module level. Since the module runs once, an
export const cache = new Map()is global to the whole application. Sometimes that is exactly what you want; other times it is a headache in the Module 8 tests. Export factory functions when each consumer should have its own. - Circular dependencies. If you see
Cannot access 'X' before initialization, look for the cycle. The fix is almost never a trick: it is redesigning so dependencies point in one direction only. - Barrels in large projects. An
index.jsthat re-exports fifty files loads them all even if you use one. Convenient, but at a cost (09-05). - Tip: order the
imports in each file by layer —external libraries first, then project modules, then the nearest ones. It is the convention the tools in 08-02 apply, and it makes the file read like a statement of intent.
Exercises
Exercise 1 — Spot the cycle. This project does not start. Draw the graph, identify the cycle and propose a reorganization with a new module.
// a/report.js
import { Board } from '../b/board.js';
export function generateReport(tasks) { return new Board('temp', tasks).summary(); }
// b/board.js
import { formatReport } from '../a/report.js';
export class Board {
print() { return formatReport(this.summary()); }
}Exercise 2 — A util/stats.js module. Create a module exporting mean(numbers), maxBy(objects, field) and groupBy(objects, field), plus a VERSION constant. None of the three may import anything from the model (they must work for any array of objects). Then write an app.js that imports it and calculates, over the canonical backlog: the mean number of hours of the open tasks, the task with the most hours, and the grouping by priority.
Exercise 3 — Shared counter. Create data/metrics.js exporting record(event) and report(), keeping the count in module state. Import it from model/task.js and from model/board.js, record an event on every status change, and show with a console trace that the two modules share the same counter. Then explain in two lines when that would be a problem.
Solutions
Exercise 1
The cycle is direct: report.js → board.js → report.js.
flowchart LR
I["a/report.js"] -->|"import Board"| T["b/board.js"]
T -->|"import formatReport"| I
The underlying cause is a layer inversion: Board is a model piece and should know nothing about how reports are formatted. The fix is not an import trick, but sorting out the responsibilities:
// util/report-format.js — bottom layer: imports nothing
export function formatReport(summary) {
return `${summary.total} tasks · ${summary.openHours} h open · effort ${summary.effort}`;
}
// model/board.js — middle layer: depends only on utilities
export class Board {
summary() { /* … */ }
// print() disappears: the board calculates, it does not present
}
// reports/report.js — top layer: knows about the two below
import { Board } from '../model/board.js';
import { formatReport } from '../util/report-format.js';
export function generateReport(tasks) {
return formatReport(new Board('temp', tasks).summary());
}The resulting graph, cycle-free and with every arrow pointing the same way:
flowchart TD
REP["reports/report.js"] --> BOA["model/board.js"]
REP --> FMT["util/report-format.js"]
BOA --> FMT
The general lesson: when a cycle appears, there is almost always a layer looking upwards. Here it was Board.print(), a presentation method in a model class.
Exercise 2
// js/util/stats.js
export const VERSION = '1.0.0';
/** Arithmetic mean of an array of numbers. Returns 0 if it is empty. */
export function mean(numbers) {
if (numbers.length === 0) return 0;
return numbers.reduce((s, n) => s + n, 0) / numbers.length;
}
/** The object with the highest value in the given field, or null if the array is empty. */
export function maxBy(objects, field) {
return objects.reduce((best, current) =>
(best === null || current[field] > best[field]) ? current : best, null);
}
/** Groups by the value of a field: { high: [...], medium: [...] } */
export function groupBy(objects, field) {
return objects.reduce((acc, obj) => {
const key = obj[field] ?? 'no value';
(acc[key] ??= []).push(obj);
return acc;
}, {});
}// js/app.js
import { createBacklog } from './data/backlog.js';
import { mean, maxBy, groupBy, VERSION } from './util/stats.js';
const backlog = createBacklog();
const open = backlog.filter((t) => t.isOpen);
console.log(`stats v${VERSION}`);
console.log(mean(open.map((t) => t.estimatedHours)).toFixed(1)); // '9.0' (45 / 5)
console.log(maxBy(backlog, 'estimatedHours').title); // 'Update the bookings website'
const byPriority = groupBy(backlog, 'priority');
for (const [priority, tasks] of Object.entries(byPriority)) {
console.log(`${priority}: ${tasks.length}`);
}
// high: 3 · medium: 2 · low: 1The constraint in the statement —that it must not import anything from the model— is the point of the exercise. mean, maxBy and groupBy work just as well with tasks, with residents or with room bookings, and that is why they live in util/ and depend on nobody: they are a leaf of the graph. A utility module that imports from the model stops being reusable and is the seed of the next cycle. Note as well that maxBy returns the first maximum in the event of a tie, because the comparison is strict (>).
Exercise 3
// js/data/metrics.js
const counts = new Map(); // module state: a single one for the whole application
export function record(event) {
counts.set(event, (counts.get(event) ?? 0) + 1);
}
export function report() {
return Object.fromEntries([...counts.entries()].sort((a, b) => b[1] - a[1]));
}
export function reset() { // essential for the Module 8 tests
counts.clear();
}// js/model/task.js (added)
import { record } from '../data/metrics.js';
changeStatus(next) {
// …R6 validation…
this.#status = next;
record(`task:${next}`);
return this;
}// js/model/board.js (added)
import { record } from '../data/metrics.js';
changeStatus(id, next) {
// …
task.changeStatus(next);
record('board:statusChange');
return this;
}// js/app.js
import { report } from './data/metrics.js';
board.changeStatus(2, 'in-progress');
board.changeStatus(3, 'in-progress');
board.changeStatus(1, 'done');
console.log(report());
// { 'board:statusChange': 3, 'task:in-progress': 2, 'task:done': 1 }That the report adds up the calls made from two different modules proves that both received the same instance of metrics.js: the module was loaded and run once, and that Map is unique.
When is it a problem? When the shared state should be independent. In the Module 8 tests, each test would inherit the previous one's counts and would fail intermittently —hence why we exported reset(). And if the application showed two boards at once, the metrics of both would blend together with no way to separate them. The alternative is the same one we applied in createBacklog(): export a factory (createMetrics()) so each consumer has its own instance, and reserve module state for what really is global, such as the application's configuration.
Conclusion
The project has stopped being a file and become an architecture. The three problems from the beginning are solved by construction: every module has its own scope, so two files can declare WEIGHTS without either noticing; load order is worked out by the engine from the imports, not by the HTML; and dependencies are explicit, written in the first lines of each file, to the point where you can draw the graph and check at a glance that every arrow points downwards.
You have the complete syntax down. export in front of a declaration or grouped in a list at the end; import { a, b } with names that must match —it is not destructuring—; as to rename at either end, as you did with isOverdue as dateOverdue to resolve a real clash; import * as ns to bring in the whole namespace; export default for the "this file is a single thing" case, with its pros and cons against named exports; and re-exporting through barrels, convenient but with a loading cost worth knowing about. You know that a module runs only once and works like a singleton, that imports are read-only live bindings, and that this is why createBacklog() is a function and not an exported array.
On the environment side you have the full picture: <script type="module"> with its own scope, its permanent strict mode, its implicit defer —which will come in very handy in Module 6— and its requirement of a local server because of CORS; dynamic import() and module-level await as syntax you will pick up again in 05-06 and 09-05; CommonJS with require/module.exports so you recognize it when it turns up, with its table of differences against ESM —static versus dynamic resolution, live bindings versus copies, browser support or none—; and the "type": "module" in package.json that lets you run the project with node js/app.js. And you know how to diagnose a circular dependency: not as an import problem to be fixed with a trick, but as the symptom of a layer looking upwards.
Nómada Tasks now lives in model/task.js, model/board.js, model/errors.js, data/backlog.js, util/dates.js, util/format.js and js/app.js, and it still gives the same numbers as always: 48 h in total, 45 open, 1 overdue, effort 124, Iván on 25 h, Lucía on 14 and Marta on 6. With one important difference: that data/backlog.js is currently a hand-written array, and everybody knows that in the real application the data will come from somewhere —a file, a server— and will take time to arrive. A single-threaded language cannot sit there waiting with its arms folded while that happens, because while it waits the entire interface freezes. How to program a wait without blocking anything is the course's other great leap, and it starts in Asynchronous JavaScript: Callbacks.
JavaScript Course: From Beginner to Advanced
Module 1: Introduction to JavaScript
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
