← back to section

A real project is not one file but dozens or hundreds. For code in one file to use functions from another, there are modules. And to bring in third-party code (libraries) without copying it manually, there is npm — the package manager from the Node.js world. Let's look at both in turn.

Why modules exist

If all code lived in one global scope, any two variables with the same name would conflict, and it would be impossible to tell where a function came from. A module solves this simply: each file is a separate namespace. Everything inside a file is private; only what you explicitly export is visible to the outside, and you can only use someone else's code through an explicit import.

Short formula: one file — one module, and the connections between files are visible in import lines.

ES modules: import and export

The modern standard is ES modules (ESM). This is the import/export syntax, shared by browsers and Node. It is what you write in TypeScript by default.

// file: math.ts
export function add(a: number, b: number): number {
  return a + b;
}

export const PI = 3.14159;

// "export default" — one main value per module
export default class Calculator {
  // ...
}
// file: app.ts
import Calculator, { add, PI } from "./math"; // default + named
import { add as plus } from "./math";          // rename on import
import * as math from "./math";                // entire module namespace at once

console.log(add(2, 3)); // 5
console.log(math.PI);   // 3.14159

Key things to remember:

  • A named export (export function add) is imported in curly braces using the same name.
  • A default export (export default) — one per file; you choose the name when importing.
  • A path to your own file starts with ./ or ../. A path without a dot (import { z } from "zod") refers to a package from node_modules.

CommonJS: require and module.exports

Before ESM arrived in Node, it had its own module system — CommonJS (CJS). You will encounter it in legacy code and many libraries:

// export
function add(a, b) {
  return a + b;
}
module.exports = { add };

// import
const { add } = require("./math");

How CommonJS differs from ES modules in practice:

  • require() is a plain function call that can appear in the middle of a file or inside a conditional. import is a declaration that is hoisted to the top and runs before any other code.
  • CommonJS loads modules synchronously; ESM is designed for asynchronous loading and static analysis (bundlers can tree-shake unused exports).
  • ESM is the future and the standard; CommonJS is the legacy that will stick around for a long time.

In Node, which system your .js file uses is determined by the "type" field in package.json: "type": "module" means ESM, "type": "commonjs" (or no field) means CommonJS. In TypeScript you almost always write import/export; the compiler and project settings decide what they compile down to.

package.json — the project's passport

Every Node project is described by a package.json file in the root. It is a plain JSON document with project metadata and, most importantly, the list of dependencies. Create one with npm init (npm init -y fills in defaults automatically).

{
  "name": "my-service",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/app.js",
    "dev": "tsx watch src/app.ts",
    "test": "vitest"
  },
  "dependencies": {
    "express": "^4.19.2",
    "zod": "^3.23.8"
  },
  "devDependencies": {
    "typescript": "^5.4.5",
    "tsx": "^4.10.0",
    "vitest": "^1.6.0"
  }
}

dependencies vs devDependencies

Dependencies are split into two groups, and the difference is not cosmetic:

  • dependencies — packages the application cannot run without in production. For example, the web framework express or the validator zod. They go to the server along with the application.
  • devDependencies — tools needed only during development and building: the typescript compiler itself, test runners, linters. They are not needed on a production server running the built application.

The commands put a package into the right group: npm install express goes into dependencies; npm install --save-dev typescript (shorter: npm i -D typescript) goes into devDependencies. Correct separation reduces the production build size and shrinks the attack surface.

semver — versions like 4.19.2

A package version number is semver (semantic versioning), three numbers MAJOR.MINOR.PATCH:

  • MAJOR (4.x.x) — breaking changes; old code may break.
  • MINOR (x.19.x) — new features without breaking changes.
  • PATCH (x.x.2) — bug fixes that break nothing.

In package.json, a symbol before the version specifies the allowed update range:

  • ^4.19.2 — caret: anything up to the next MAJOR (4.x.x, but not 5.0.0). The most common choice.
  • ~4.19.2 — tilde: only PATCH updates allowed (4.19.x).
  • 4.19.2 — exactly this version, no updates.

The exact versions that were actually installed are pinned in package-lock.json (for npm). This file is committed to the repository — it guarantees that you and your colleagues get exactly the same packages.

node_modules and installation

The command npm install (without arguments) reads package.json, downloads all dependencies, and places them in the node_modules folder in the project root. The dependencies of your dependencies end up there too — which is why the folder grows enormous.

Two rules about node_modules:

  • Do not commit it to git — it can be restored from package.json plus the lock file with a single command. Add node_modules/ to .gitignore.
  • If something "breaks for no clear reason", a common fix is to delete node_modules and the lock file, then reinstall.

npm, pnpm, and others

npm is the default manager, bundled with Node. There are alternatives that use the same package.json: pnpm (stores packages in one shared location and uses links — saves disk space and installs faster) and yarn. npm is enough to start; pnpm is worth knowing because many new projects choose it.

npm scripts

The scripts block in package.json holds named project commands. They are run via npm run <name>:

npm run build   # runs "tsc" — compiles TypeScript
npm run dev     # starts dev mode with auto-reload
npm test        # for test/start/stop the word "run" can be omitted

Scripts eliminate the need to memorise long commands and document how to work with the project: anyone who opens package.json immediately sees how to build, run, and test it.

In short

  • A module is a file; only export is visible to the outside, and you take someone else's code only via import.
  • ES modules (import/export) are the modern standard; CommonJS (require) is the legacy found in Node code and older libraries.
  • package.json is the project's passport: metadata, dependencies, and scripts.
  • dependencies are needed in production; devDependencies are for development and building only.
  • semver MAJOR.MINOR.PATCH; ^ allows updates up to the next MAJOR; the lock file pins exact versions.
  • node_modules is restored from package.json and is not committed to git.
  • npm scripts are run via npm run <name> and document how to work with the project.
  • JavaScript basics for TypeScript — the language everything runs on top of.
  • Tooling: compiler and tsconfig — how tsc and its settings turn your modules into executable code.
  • Async and the event loop — why ESM loads asynchronously and how Node executes it.