Learn / JavaScript fundamentals
LESSON 18 OF 84

JavaScript fundamentals Foundation

Modules

Split a program into files with ES modules, understand the older CommonJS require, choose between named and default exports, and avoid circular dependencies.

  • 40 min to read and try
  • You need: Asynchronous JavaScript, and the lessons before it
  • You build: A task feature split into modules with a clear public entry point, and a circular dependency found and fixed
Test yourself

Why modules exist

Real backends have hundreds of files. Putting everything in one file does not work: it becomes impossible to find anything, two parts of the code accidentally use the same variable name, and any function can change any data.

A module is a file with its own private scope. Nothing inside it is visible to other files unless the module exports it, and another file must import it by name to use it. That gives you three things:

  • Organisation: each file does one job and has a name that says what it is.
  • No name clashes: a variable called tasks in one file has nothing to do with tasks in another. That is the module scope from Scope and how code runs.
  • Hiding: a module decides exactly what the rest of the program may touch. Everything else is an internal detail you can change safely.

JavaScript has two module systems. ES modules (ESM), with import and export, are the standard, and what this course uses. CommonJS, with require, is the older Node.js system that you will still meet. This lesson covers both.

ES modules: import and export

Make a new folder for this lesson, run npm init -y and npm pkg set type=module in it as in Your first program, then create two files. The first one holds the tasks:

tasks.js
const tasks = [
  { id: 1, title: "Buy milk", done: false },
  { id: 2, title: "Write report", done: true },
];

let nextId = 3;

export function listTasks() {
  return tasks;
}

export function addTask(title) {
  const task = { id: nextId, title, done: false };
  nextId += 1;
  tasks.push(task);
  return task;
}

The second one uses it:

main.js
import { addTask, listTasks } from "./tasks.js";

addTask("Call Ada");
console.log(listTasks());
Output of node main.js and of the browser terminal
[
  { id: 1, title: 'Buy milk', done: false },
  { id: 2, title: 'Write report', done: true },
  { id: 3, title: 'Call Ada', done: false }
]

Run main.js, not tasks.js. The browser terminal knows about both files on this page, so Run in browser works too. On your computer:

Terminal on your computer
node main.js
[
  { id: 1, title: 'Buy milk', done: false },
  { id: 2, title: 'Write report', done: true },
  { id: 3, title: 'Call Ada', done: false }
]

A few rules to remember:

  • A relative import starts with ./ (this folder) or ../ (the folder above) and includes the file extension: "./tasks.js", not "./tasks".
  • tasks and nextId are not exported, so no other file can reach them. The only way to add a task is through addTask, so ids can never be duplicated.
  • A package import has no ./: import { schema } from "@zudojs/schema". Node.js finds it in the node_modules folder that npm install fills. npm and packages covers that.
  • import lines go at the top of the file. They run before any other code in the file.

WATCH OUT

If you see SyntaxError: Cannot use import statement outside a module, your package.json is missing "type": "module". Run npm pkg set type=module.

Named and default exports

Everything you exported so far is a named export: the importing file must use the exact name, inside { }. A module may also have one default export, written export default. The importing file picks any name for it, with no braces:

task-store.js
export const MAX_TASKS = 100;

export default class TaskStore {
  #tasks = [];

  add(title) {
    if (this.#tasks.length >= MAX_TASKS) throw new Error("store is full");
    this.#tasks.push(title);
    return this.#tasks.length;
  }
}
main.js
import Store, { MAX_TASKS } from "./task-store.js";
import Whatever from "./task-store.js";

const store = new Store();
console.log(store.add("Buy milk"), "of", MAX_TASKS);
console.log(Store === Whatever);
Output of node main.js and of the browser terminal
1 of 100
true

Store and Whatever are the same class, under two different names. That freedom is the problem with default exports: every file can call the same thing something different, so searching the code for TaskStore misses them, and editors cannot rename it reliably. A typo in a named import is an error; a "typo" in a default import is just a new name.

TIP

Prefer named exports. They keep one name for one thing across the whole codebase. ZudoJS packages only use named exports. You will still see export default in some libraries and frameworks, so you need to recognise it.

Import aliases and namespaces

Sometimes two modules export the same name. Rename one when you import it with as. Or import the whole module as one object with import * as name, called a namespace import:

tasks.js
export function create(title) {
  return { kind: "task", title };
}

export function count(list) {
  return list.filter((item) => item.kind === "task").length;
}
users.js
export function create(name) {
  return { kind: "user", name };
}
main.js
import { create as createTask } from "./tasks.js";
import { create as createUser } from "./users.js";
import * as taskModule from "./tasks.js";

const items = [createTask("Buy milk"), createUser("Ada"), createTask("Call Ada")];
console.log(items);
console.log(taskModule.count(items));
console.log(typeof taskModule.create);
Output of node main.js and of the browser terminal
[
  { kind: 'task', title: 'Buy milk' },
  { kind: 'user', name: 'Ada' },
  { kind: 'task', title: 'Call Ada' }
]
2
function

Inside main.js, the two create functions are now createTask and createUser. The namespace taskModule holds every export of tasks.js as a property, which makes it obvious where count comes from.

A module runs once

However many files import a module, its code runs only the first time. Every importer gets the same exports, so they share any state inside it:

counter.js
console.log("counter.js is running");

let count = 0;

export function next() {
  count += 1;
  return count;
}
tasks.js
import { next } from "./counter.js";

export const taskId = () => `task-${next()}`;
users.js
import { next } from "./counter.js";

export const userId = () => `user-${next()}`;
main.js
import { taskId } from "./tasks.js";
import { userId } from "./users.js";

console.log(taskId(), userId(), taskId());
Output of node main.js and of the browser terminal
counter.js is running
task-1 user-2 task-3

counter.js printed its message once, and the two modules share one count. This is useful (one database connection for the whole app, created in one module) and a trap (state you thought was private to tasks.js is shared). If each part needs its own counter, export a function that creates one, like makeCounter in Scope.

CommonJS: require and module.exports

Before ES modules existed, Node.js had its own system, CommonJS. Many older packages and tutorials still use it, so you need to read it. A CommonJS file assigns what it shares to module.exports, and another file loads it with require.

In a project with "type": "module", a file must end in .cjs to be treated as CommonJS. (In a project without "type": "module", every .js file is CommonJS.) Here is the task store again:

tasks.cjsNode.js only
const tasks = [];

function addTask(title) {
  const task = { id: tasks.length + 1, title, done: false };
  tasks.push(task);
  return task;
}

function listTasks() {
  return tasks;
}

module.exports = { addTask, listTasks };
main.cjsNode.js only
const { addTask, listTasks } = require("./tasks.cjs");

addTask("Buy milk");
addTask("Call Ada");
console.log(listTasks());
console.log(typeof require, typeof module);
Output of npx tsx main.cjs
[
  { id: 1, title: 'Buy milk', done: false },
  { id: 2, title: 'Call Ada', done: false }
]
function object
Terminal on your computer
node main.cjs
[
  { id: 1, title: 'Buy milk', done: false },
  { id: 2, title: 'Call Ada', done: false }
]
function object

The ideas are the same, the spelling is different:

  • require is an ordinary function. It loads the file right then, synchronously, and returns its module.exports object. import is special syntax that Node.js reads before running anything.
  • const { addTask } = require(...) is just destructuring the returned object.
  • These examples are "Node.js only": the browser terminal only understands ES modules.

An ES module can import a CommonJS file. Its module.exports object arrives as the default export, and Node.js also detects simple named exports. The reverse, require in an ES module, does not exist:

app.jsNode.js only
import tasks from "./tasks.cjs";
import { addTask } from "./tasks.cjs";

addTask("Plan week");
console.log(tasks.listTasks());
console.log(typeof require);
Output of node app.js
[ { id: 1, title: 'Plan week', done: false } ]
undefined

For new code, always write ES modules. Use this knowledge to read older code and to understand why some packages are imported with a default import.

Module boundaries: a public entry point

As a feature grows, it becomes several files: one that stores data, one with the rules, maybe one with helpers. Other parts of the app should not reach into all of them. Give the feature's folder one entry point, usually index.js, that re-exports only the public functions. export { name } from "./file.js" re-exports without importing into the current file:

tasks/store.jsNode.js only
const rows = [];

export function insert(task) {
  rows.push(task);
  return task;
}

export function all() {
  return rows.map((row) => ({ ...row }));
}
tasks/service.jsNode.js only
import { all, insert } from "./store.js";

export function createTask(title) {
  const clean = title.trim();
  if (clean === "") throw new Error("title must not be empty");
  return insert({ id: all().length + 1, title: clean, done: false });
}

export function listTasks() {
  return all();
}
tasks/index.jsNode.js only
export { createTask, listTasks } from "./service.js";
main.jsNode.js only
import * as tasks from "./tasks/index.js";

tasks.createTask("  Buy milk ");
console.log(tasks.listTasks());
console.log(Object.keys(tasks));
Output of node main.js
[ { id: 1, title: 'Buy milk', done: false } ]
[ 'createTask', 'listTasks' ]

The rest of the app sees exactly two functions. insert exists, but it is not part of the feature's public face, so nobody can store a task that skipped the validation in createTask. You are free to replace store.js with a real database later without changing any other file. That line between "public" and "internal" is a module boundary.

NOTE

npm packages draw the same line with the "exports" field in their package.json: only the paths listed there can be imported. That is why you import @zudojs/errors, not a file deep inside it.

Circular dependencies

A circular dependency is when module A imports module B, and B (directly or through others) imports A. It usually happens by accident as a project grows. Here, tasks.js uses a logger, and the logger wants a constant from tasks.js:

tasks.js
import { logChange } from "./logger.js";

export const DEFAULT_PRIORITY = 3;

export function createTask(title) {
  const task = { title, priority: DEFAULT_PRIORITY };
  logChange("created", task);
  return task;
}
logger.js
import { DEFAULT_PRIORITY } from "./tasks.js";

const prefix = `[tasks, default priority ${DEFAULT_PRIORITY}]`;

export function logChange(event, task) {
  console.log(`${prefix} ${event}: ${task.title}`);
}
main.js
import { createTask } from "./tasks.js";

createTask("Buy milk");
Terminal on your computer
node main.js
file://~/modules-demo/cycle/logger.js:3
const prefix = `[tasks, default priority ${DEFAULT_PRIORITY}]`;
                                           ^

ReferenceError: Cannot access 'DEFAULT_PRIORITY' before initialization
    at file://~/modules-demo/cycle/logger.js:3:44
    at ModuleJob.run (node:internal/modules/esm/module_job:439:25)
    at async node:internal/modules/esm/loader:643:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v24.19.0

Follow what Node.js did:

  1. main.js imports tasks.js. Before running tasks.js, Node.js must run what it imports: logger.js.
  2. logger.js imports tasks.js, which is already being loaded, so Node.js does not start it again. It runs logger.js first.
  3. logger.js reads DEFAULT_PRIORITY on line 3, but the line in tasks.js that creates it has not run yet. That is the temporal dead zone from Scope, across two files.

This error message, "Cannot access ... before initialization" pointing at an imported name, is the typical sign of a circular import. CommonJS is worse: if you rewrite the same three files with require, nothing crashes. require returns the half-finished exports object, the value is quietly undefined, and you only get a warning:

Terminal on your computer
node main.cjs
[tasks, default priority undefined] created: Buy milk
(node:258000) Warning: Accessing non-existent property 'DEFAULT_PRIORITY' of module exports inside circular dependency
(Use `node --trace-warnings ...` to show where the warning was created)

The fix: break the cycle

Move what both modules need into a third module that imports neither of them. Dependencies then point one way only:

constants.js
export const DEFAULT_PRIORITY = 3;
logger.js
import { DEFAULT_PRIORITY } from "./constants.js";

const prefix = `[tasks, default priority ${DEFAULT_PRIORITY}]`;

export function logChange(event, task) {
  console.log(`${prefix} ${event}: ${task.title}`);
}
tasks.js
import { DEFAULT_PRIORITY } from "./constants.js";
import { logChange } from "./logger.js";

export function createTask(title) {
  const task = { title, priority: DEFAULT_PRIORITY };
  logChange("created", task);
  return task;
}
main.js
import { createTask } from "./tasks.js";

createTask("Buy milk");
Output of node main.js and of the browser terminal
[tasks, default priority 3] created: Buy milk

Now main.jstasks.jslogger.jsconstants.js, and tasks.jsconstants.js. No arrow points back. Other ways to break a cycle: pass the value in as an argument instead of importing it, or merge two modules that cannot live without each other.

Choosing a module structure

There are two common ways to arrange the files of a backend:

  • By technical type: a routes/ folder, a services/ folder, a models/ folder. Every feature is spread across all of them.
  • By feature: a tasks/ folder, a users/ folder, each holding its own routes, service and store, with one index.js entry point.
src/
  main.js
  shared/
    errors.js
    constants.js
  tasks/
    index.js        public entry point
    service.js
    store.js
  users/
    index.js
    service.js
    store.js

Organising by feature scales better. Everything about tasks is in one place, a feature can be understood (or deleted) on its own, and the entry points keep features from reaching into each other's internals. A few rules keep it healthy:

  • A feature imports another feature only through its index.js.
  • Shared code lives in shared/ and never imports from a feature. That rule alone prevents most circular dependencies.
  • One module, one job. When a file grows past a screen or two, split it.

ZudoJS projects use this feature-based layout, and Backend architecture explains the layers inside each feature.

Practice

TRY IT YOURSELF

Split a file into modules

Move formatTask into its own file format.js as a named export, and isOverdue into dates.js. Import both into main.js and print the result for one task that is overdue.

Show a solution
format.js
export function formatTask(task) {
  return `${task.done ? "[x]" : "[ ]"} ${task.title}`;
}
dates.js
export function isOverdue(task, today) {
  return !task.done && task.due < today;
}
main.js
import { isOverdue } from "./dates.js";
import { formatTask } from "./format.js";

const task = { title: "File taxes", done: false, due: "2026-04-30" };
const warning = isOverdue(task, "2026-09-23") ? " (overdue)" : "";
console.log(formatTask(task) + warning);
Output of node main.js and of the browser terminal
[ ] File taxes (overdue)

The dates are strings in YYYY-MM-DD form, which sort correctly as text, so < works on them.

TRY IT YOURSELF

Read some CommonJS

Rewrite this CommonJS module and its user as ES modules with named exports.

old.cjsNode.js only
const LIMIT = 20;

function paginate(items, page) {
  return items.slice((page - 1) * LIMIT, page * LIMIT);
}

module.exports = { LIMIT, paginate };

// in another file:
// const { paginate } = require("./old.cjs");
Show a solution
paginate.js
export const LIMIT = 20;

export function paginate(items, page) {
  return items.slice((page - 1) * LIMIT, page * LIMIT);
}
main.js
import { LIMIT, paginate } from "./paginate.js";

const items = Array.from({ length: 45 }, (_, i) => i + 1);
console.log(LIMIT, paginate(items, 3));
Output of node main.js and of the browser terminal
20 [ 41, 42, 43, 44, 45 ]

TRY IT YOURSELF

Spot the cycle

users.js imports getTasksFor from tasks.js, and tasks.js imports findUser from users.js. Both only use the imported function inside their own functions, never at the top level. Will it crash? Is it still a problem?

Show a solution

It will usually not crash. By the time any function is called, both modules have finished loading, so both imports are ready. Only code that runs at the top level while the modules are still loading, like the prefix line above, hits the error. It is still a warning sign: the two modules cannot be understood or tested separately, and the first top-level use of either import will break. Move the shared logic into a third module, or have one side receive the function as an argument.

Recap

  • Each file is a module with its own scope. export what others may use, and import it by relative path with the extension.
  • Prefer named exports. A default export can be imported under any name, which makes code harder to search.
  • import { a as b } renames an import, and import * as ns gathers all exports into one object.
  • A module runs once, and every importer shares its state.
  • CommonJS uses require and module.exports (.cjs files in an ES module project). Read it, but write ES modules.
  • Give each feature one public entry point, and keep its internals private.
  • A circular import shows up as "Cannot access ... before initialization" (ESM) or a silent undefined (CommonJS). Break it by moving the shared part into a third module.

That completes the JavaScript part. Next, you will leave the browser behind and look at Node.js itself: the runtime that runs your JavaScript on a server.

Test yourself

Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.