Skip to content
learn

1. Node.js, npm and modules

Code: examples/l01_hello.js, the projects l01-esm, l01-cjs and l01-interop, and the error snippets in errors/.

In .NET and Java, one product gives you the language, the runtime and the standard library. JavaScript splits them in three:

C# Java JavaScript
The language C#, compiled by Roslyn Java, compiled by javac ECMAScript, parsed by the engine at load time
The runtime the CLR the JVM an engine: V8, SpiderMonkey or JavaScriptCore
Files, network, processes the base class library the JDK the host: Node.js, Deno, Bun, or a browser

The ECMAScript specification defines values, objects, functions, Array, Map, JSON and Promise, and nothing that touches the outside world: no files, no network, not even console.log. The host adds those. Node.js embeds V8 and adds modules such as node:fs, a process object and an event loop; a browser embeds its engine and adds document and window.

flowchart LR
source["Your .js files"] --> engine["Engine: V8<br/>parses, compiles, runs ECMAScript"]
node["Node.js host<br/>process, node:fs, node:http, event loop"] --> engine
browser["Browser host<br/>window, document, DOM events"] -.-> engine2["Engine: V8, SpiderMonkey<br/>or JavaScriptCore"]
both["In both hosts<br/>console, setTimeout, fetch, URL"] --> engine
both -.-> engine2

So the same language gives different globals depending on where it runs:

> node -p "typeof window"
undefined
> node -p "typeof process"
object
> node -p "process.versions.v8"
13.6.233.17-node.53

This course runs everything in Node.js. The browser is the subject of lesson 12, and the DOM belongs to the React course.

Node.js publishes a major version every six months. Even-numbered versions become long-term support (LTS) releases in October, and each LTS release is supported for about 30 months (release schedule):

Released LTS from End of life
Node.js 22 “Jod” April 2024 October 2024 April 2027
Node.js 24 “Krypton” May 2025 October 2025 April 2028
Node.js 26 May 2026 October 2026 April 2029

That is close to .NET, where every even-numbered release is LTS for three years, and to Java, where a new LTS version comes every two years. This course uses Node.js 24.21.0, the latest 24 release on 14 September 2026.

I downloaded the ZIP archive, checked its SHA-256 against the list Node.js publishes, and expanded it in a folder of its own, outside the global PATH:

Terminal window
Invoke-WebRequest https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip -OutFile node-v24.21.0-win-x64.zip
(Get-FileHash node-v24.21.0-win-x64.zip -Algorithm SHA256).Hash.ToLower()
(Invoke-RestMethod https://nodejs.org/dist/v24.21.0/SHASUMS256.txt) -split "`n" | Select-String 'win-x64.zip'
Expand-Archive node-v24.21.0-win-x64.zip -DestinationPath C:\tools # creates C:\tools\node-v24.21.0-win-x64
C:\tools\node-v24.21.0-win-x64\node.exe --version

The two hashes must be identical: on my machine, both were 158f7685…e541, and the four commands took 22 seconds. Add the folder to your PATH to call node and npm from any terminal.

The installer is another option: winget install OpenJS.NodeJS.LTS (to verify: I haven’t run it). On 14 September 2026, winget show OpenJS.NodeJS.LTS still offered 24.19.0, two releases behind.

Then, on any OS:

> node --version
v24.21.0
> npm --version
11.19.0

npm comes with Node.js: each Node.js release bundles a version of npm.

examples/l01_hello.js
const names = process.argv.slice(2); // argv[0] is node, argv[1] is this file
console.log('Hello, world!');
for (const name of names) {
console.log(`Hello, ${name}!`);
}
> node examples/l01_hello.js Ada Grace
Hello, world!
Hello, Ada!
Hello, Grace!
C# Java Node.js
Run one file dotnet run app.cs (.NET 10) java Hello.java node hello.js
Evaluate an expression — — node -p "0.1 + 0.2"
Interactive shell C# Interactive in Visual Studio jshell node, then .exit
Rerun on every change dotnet watch — node --watch hello.js
Program arguments args String[] args process.argv, from index 2

There is no compilation step to run: Node.js reads the source, V8 compiles it on the fly. console.log is the Console.WriteLine of JavaScript; template literals, between backticks, interpolate like $"…" in C#. Lesson 2 explains const.

A folder becomes a project when it has a package.json. npm init -y writes one with default values:

> npm init -y
Wrote to C:\…\npmdemo\package.json:
{
"name": "npmdemo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs"
}

npm 11.19 writes "type": "commonjs" explicitly; the modules section below explains why that field matters. Installing a package adds it to dependencies, downloads it into node_modules, and writes package-lock.json:

> npm install semver@7.7.2
added 1 package, and audited 2 packages in 782ms
found 0 vulnerabilities
"dependencies": {
"semver": "^7.7.2"
}
NuGet Maven npm
Manifest .csproj, <PackageReference> pom.xml, <dependency> package.json, dependencies
Build or test only PrivateAssets="all" <scope>test</scope> devDependencies
A version written 7.7.2 means 7.7.2 or higher (NuGet versioning) a soft requirement for 7.7.2 (POM reference) exactly 7.7.2
Default range when you add a package the version, as a minimum the version ^7.7.2: 7.7.2 or higher, below 8.0.0 (semver)
Lock file packages.lock.json, opt-in none package-lock.json, always
Where packages go a shared cache, ~/.nuget/packages a shared cache, ~/.m2/repository a node_modules folder in each project, filled from a cache
Restore exactly the lock file dotnet restore --locked-mode — npm ci
Run a tool of a package dotnet tool run a plugin goal npx <tool>

Commit package.json and package-lock.json, never node_modules. In CI, npm ci deletes node_modules and installs exactly what the lock file lists, or fails if the lock file doesn’t match package.json.

The scripts of package.json are named commands, the closest thing to MSBuild targets or Maven goals. l01-esm/package.json has two:

{
"name": "l01-esm",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node main.js",
"greet": "node main.js Ada Grace"
}
}

npm run prints the script before running it; node --run, added in Node.js 22, runs it without npm:

> npm run greet
> l01-esm@1.0.0 greet
> node main.js Ada Grace
Hello, Ada!
Hello, Grace!
Bye, Ada!
l01-esm: main.js, typeof require = undefined
> node --run greet
Hello, Ada!
Hello, Grace!
Bye, Ada!
l01-esm: main.js, typeof require = undefined

Both add node_modules/.bin to the PATH, so a script can call the tools of installed packages by name. node --run intentionally skips the pre and post scripts that npm runs around a script (exercise 2).

This site’s package.json declares "type": "module" and scripts that call Astro: npm run build runs astro build, found in node_modules/.bin.

GuitarAlchemist/ga shows two traps of a JavaScript project maintained by C# developers:

  • Apps/ga-client has both a package-lock.json and a pnpm-lock.yaml. They are the lock files of two package managers, npm and pnpm, and nothing keeps them in agreement: a developer who runs npm install and one who runs pnpm install can get different versions of the same dependency.
  • ReactComponents/ga-react-components/package.json lists @esbuild/win32-x64 in devDependencies. That package declares "os": ["win32"], and npm refuses to install it anywhere else. The course’s CI installs it on the three OSes: it works on Windows, and on Linux npm stops with:
npm error code EBADPLATFORM
npm error notsup Unsupported platform for @esbuild/win32-x64@0.25.12: wanted {"os":"win32","cpu":"x64"} (current: {"os":"linux","cpu":"x64"})
npm error notsup Valid os: win32
npm error notsup Actual os: linux
npm error notsup Valid cpu: x64
npm error notsup Actual cpu: x64

macOS fails the same way. The folder has only a pnpm-lock.yaml, and pnpm 10 installed a package for another OS without a word on my machine, so the project probably installs with pnpm on every OS, but not with npm (to verify on Linux and macOS). The usual fix is to leave the platform packages out: esbuild lists them all as optionalDependencies, and each package manager installs the one that matches.

Node.js has two module systems. CommonJS came first, with Node.js in 2009: require loads a file, module.exports is what it exports. ES modules came with the language in ECMAScript 2015, and are what browsers understand: import and export.

CommonJS ES modules
Import const { hello } = require('./greet') import { hello } from './greet.js'
Export module.exports = { hello } export function hello…
File extension in the path optional required
Loading synchronous, when require runs the imports are resolved before the module runs
Strict mode (lesson 3) only with 'use strict' always
await at the top level no yes
The folder of the current file __dirname import.meta.dirname

In both, a module is a file: there is no namespace that spans files, and nothing is visible to other files unless it is exported. Compared with C#, export is public, everything else is private to the file, and import names the file where a using would name a namespace.

Node.js chooses the module system file by file:

  1. a .mjs file is an ES module, a .cjs file is CommonJS;
  2. a .js file follows the "type" field of the nearest package.json, "module" or "commonjs";
  3. a .js file without a "type" above it is parsed as CommonJS, and parsed again as an ES module if it contains import or export (syntax detection).

The third case works, with a warning. l01-interop/package.json has no "type", and detect.js contains an import:

> node detect.js
(node:PID) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of l01-interop/detect.js is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to l01-interop/package.json.
(Use `node --trace-warnings ...` to show where the warning was created)
8 undefined

The ES module version, in a package with "type": "module":

l01-esm/greet.js
// An ES module: what it exports is declared with export, everything else stays private to the file
const punctuation = '!';
export function hello(name) {
return `Hello, ${capitalize(name)}${punctuation}`;
}
export default function farewell(name) {
return `Bye, ${capitalize(name)}${punctuation}`;
}
function capitalize(text) {
return text.charAt(0).toUpperCase() + text.slice(1);
}
l01-esm/main.js
// "type": "module" in package.json: .js files are ES modules
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import farewell, { hello } from './greet.js'; // the extension is required
const names = process.argv.length > 2 ? process.argv.slice(2) : ['world'];
for (const name of names) {
console.log(hello(name));
}
console.log(farewell(names[0]));
// No __dirname in an ES module: import.meta gives the module's own location
const manifest = JSON.parse(await readFile(path.join(import.meta.dirname, 'package.json'), 'utf8')); // top-level await
console.log(`${manifest.name}: ${path.basename(import.meta.filename)}, typeof require = ${typeof require}`);
> node main.js
Hello, World!
Bye, World!
l01-esm: main.js, typeof require = undefined

A module has any number of named exports and at most one default export; import farewell, { hello } takes the default one under a name you choose, and hello under its own name. Built-in modules start with node:.

The CommonJS version, in a package with "type": "commonjs":

l01-cjs/greet.js
// A CommonJS module: what it exports is whatever ends up in module.exports
const punctuation = '!';
function hello(name) {
return `Hello, ${capitalize(name)}${punctuation}`;
}
function capitalize(text) {
return text.charAt(0).toUpperCase() + text.slice(1);
}
module.exports = { hello };
l01-cjs/main.js
// "type": "commonjs" in package.json: .js files are CommonJS modules
const fs = require('node:fs');
const path = require('node:path');
const { hello } = require('./greet'); // the extension can be left out
const names = process.argv.length > 2 ? process.argv.slice(2) : ['world'];
for (const name of names) {
console.log(hello(name));
}
const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
console.log(`${manifest.name}: ${path.basename(__filename)}, typeof require = ${typeof require}`);
> node main.js Ada
Hello, Ada!
l01-cjs: main.js, typeof require = function

require in an ES module. The course folder’s package.json says "type": "module":

errors/l01_require_in_esm.js
const fs = require('node:fs');
console.log(fs.existsSync('package.json'));
errors/l01_require_in_esm.js:2
const fs = require('node:fs');
^
ReferenceError: require is not defined in ES module scope, you can use import instead
This file is being treated as an ES module because it has a '.js' file extension and 'package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension.
Node.js v24.21.0

import in a CommonJS file:

errors/l01_import_in_cjs.cjs
import { readFileSync } from 'node:fs';
console.log(readFileSync);
(node:PID) Warning: Failed to load the ES module: errors/l01_import_in_cjs.cjs. Make sure to set "type": "module" in the nearest package.json file or use the .mjs extension.
(Use `node --trace-warnings ...` to show where the warning was created)
errors/l01_import_in_cjs.cjs:2
import { readFileSync } from 'node:fs';
^^^^^^
SyntaxError: Cannot use import statement outside a module
Node.js v24.21.0

The error that surprises C# and Java developers most, and TypeScript developers too: an ES module import without its extension.

errors/l01_missing_extension.js
import { show } from '../examples/show';
show('never', 'printed');

Run from the errors folder:

node:internal/modules/run_main:107
triggerUncaughtException(
^
Error [ERR_MODULE_NOT_FOUND]: Cannot find module 'examples/show' imported from errors/l01_missing_extension.js
Did you mean to import "../examples/show.js"?
{
code: 'ERR_MODULE_NOT_FOUND',
url: 'examples/show'
}
Node.js v24.21.0

Run from the course folder, with node errors/l01_missing_extension.js, the same file gives the same error without the line Did you mean to import "../examples/show.js"?. To find that hint, Node.js asks the CommonJS resolver where require would have found the file (resolve.js, lines 888-895), but it gives the resolver a parent module without a file name, and for such a parent the resolver looks for relative paths from the current folder (loader.js, lines 1021-1028). The hint appears only when ../examples/show.js also exists relative to where you typed the command. The error itself is right either way: add the extension.

An ES module can import a CommonJS module. module.exports becomes the default export, and Node.js finds the named exports by reading the source for assignments such as exports.half = …:

l01-interop/legacy.cjs
exports.half = (n) => n / 2;
exports.kind = 'CommonJS';
l01-interop/from-esm.mjs
import legacy, { half } from './legacy.cjs';
console.log('default import:', legacy);
console.log('named import: ', half(3));
default import: { half: [Function (anonymous)], kind: 'CommonJS' }
named import: 1.5

The other direction, require of an ES module, used to be impossible. It works since Node.js 22.12 without a flag, and the Node.js 24 documentation no longer calls it experimental, as long as the module doesn’t use top-level await:

l01-interop/modern.mjs
export const twice = (n) => n * 2;
export default 'ES module';
l01-interop/from-cjs.cjs
const modern = require('./modern.mjs');
console.log(modern);
console.log(modern.twice(21), modern.default);
[Module: null prototype] {
__esModule: true,
default: 'ES module',
twice: [Function: twice]
}
42 ES module

GuitarAlchemist/ga uses the extensions as intended: its front ends are "type": "module" packages, and the one configuration file written with module.exports is named postcss.config.cjs: named .js, it would be an ES module, where module doesn’t exist. This site names its scripts .mjs, such as scripts/sync-streeling.mjs, which is redundant under "type": "module" and still correct if the field is removed.

Which one to write? ES modules, for new code: they are the standard, the browser’s format, and what TypeScript and bundlers produce by default. You still meet CommonJS in older packages and configuration files.

  • ECMAScript is the language; V8 is the engine; Node.js is a host that adds files, processes and modules. A browser is another host.
  • Node.js 24 is the active LTS line until October 2026 and stays supported until April 2028; npm comes with it.
  • package.json is the manifest and package-lock.json the lock file; commit both, and install in CI with npm ci.
  • npm install writes ^ ranges: any later minor or patch version is accepted, and the lock file is what pins the exact one.
  • A .js file is an ES module or a CommonJS module according to the "type" of the nearest package.json; .mjs and .cjs force the choice.
  • ES modules need the file extension in relative imports, are always strict, and allow top-level await.
  1. Rename l01-cjs/greet.js to greet.mjs and rewrite it as an ES module, but keep main.js a CommonJS module. What must change in main.js?
Solution

Only the path: require('./greet') doesn’t find greet.mjs, because require only tries the extensions .js, .json and .node. solutions/l01-ex1:

main.js
const greet = require('./greet.mjs');
console.log(greet.hello('world'));
console.log('exports of greet.mjs:', Object.keys(greet));
Hello, World!
exports of greet.mjs: [ 'hello' ]

greet.mjs is loaded synchronously because it has no top-level await. Add one, and require throws ERR_REQUIRE_ASYNC_MODULE.

  1. In a copy of l01-esm, add a script "pregreet": "node -e \"console.log('pregreet runs')\"". Run npm run greet, then node --run greet. What differs?
Solution
> npm run greet
> l01-esm@1.0.0 pregreet
> node -e "console.log('pregreet runs')"
pregreet runs
> l01-esm@1.0.0 greet
> node main.js Ada Grace
Hello, Ada!
Hello, Grace!
Bye, Ada!
l01-esm: main.js, typeof require = undefined
> node --run greet
Hello, Ada!
Hello, Grace!
Bye, Ada!
l01-esm: main.js, typeof require = undefined

npm runs pregreet before greet and prints each command; node --run runs only greet, silently. The Node.js documentation lists this as a deliberate limitation. A project that relies on pre scripts, such as pretest to build before testing, must keep npm run.

  1. Add "type": "module" to l01-interop/package.json and run node detect.js again, then node from-cjs.cjs. What changes, and why don’t the .mjs and .cjs files care?
Solution

node detect.js prints 8 undefined without the warning: the .js file is now an ES module by declaration, and Node.js doesn’t parse it twice. from-cjs.cjs, legacy.cjs and modern.mjs behave exactly as before, since their extension decides before "type" is read. Adding "type": "module" to a package changes only its .js files: that is why a project that switches often renames a few configuration files to .cjs, like GA’s postcss.config.cjs.