Modules, packages, and the toolchain · Lesson 62 of 77 · concept

Packages and package.json

About 50 minutes.

By the end of this lesson you can

  • Say what a package is and what makes a folder into one.
  • Read a package.json and name what each of its common fields controls.
  • Explain the difference between dependencies and devDependencies, and why it matters.
  • Trace a bare specifier like "date-fns" from your import line to the code it runs.
  • Say what node_modules is, and why it is neither edited nor committed.

Read, not run. The sandbox on this site has no modules, the DOM, the network and the filesystem, so the examples in this lesson are shown rather than executed. Where an example cannot run, it says so and explains why instead of leaving you to find out.

A package is a folder with a label on it

Explanation

Last lesson ended with a bare specifier - import { format } from "date-fns" - and a promise to explain where that comes from. This is that lesson. A package is a folder of code with a file called package.json at its root describing what the folder is: its name, its version, and which file to start from. That description is what turns a directory into something another project can depend on.

Your own project is a package too, even if you never publish it. The same file that lets a library say "I am date-fns version 4.1.0, start at src/index.js" lets your app say "I am kettle-timer, I depend on these four things, and here are the commands for building and testing me".

npm is the registry those packages are published to and the command-line tool that fetches them. When you run npm install, it reads the dependency list in your package.json, downloads each package and everything those packages themselves depend on, and puts the whole lot in a folder called node_modules.

The fields you will actually meet

Explanation

name and version. The npm documentation calls these the most important fields, and they are required if you plan to publish: together they identify a specific release of a specific package. Next lesson is about the version number's grammar.

main, and the more modern exports. main names the file someone gets when they import your package by its bare name. Node's own documentation describes exports as the modern alternative: it can declare several entry points, vary them by environment, and - the part that matters - prevent importing any file you did not list. Where both are present, exports wins.

type. This is the switch that decides how Node reads your .js files. With "type": "module" they are ES modules, the import/export syntax of the last two lessons. Without it, or with "type": "commonjs", they are CommonJS and use require instead. The extensions .mjs and .cjs override the switch file by file. If you have ever hit "Cannot use import statement outside a module", this field is where the answer lives.

scripts. A named list of commands the project knows how to run - test, build, lint, dev. Lesson six is about why this small object is the most useful thing in the file.

dependencies and devDependencies. Two lists, and the split is the subject of the next section.

Node.js — Modules: Packages

Check there: A .js file is loaded as an ES module when the nearest parent package.json has "type": "module"; .mjs and .cjs always win over that field; and "exports" supports multiple entry points and takes precedence over "main".

dependencies versus devDependencies

Why it matters

dependencies are the packages your code needs in order to RUN. If you deleted one, the shipped product would break. devDependencies are the ones you need in order to WORK: the test runner, the formatter, the type checker, the bundler. Delete one of those and your product still runs perfectly; you just cannot test or build it comfortably.

The distinction is not bookkeeping. Anyone who installs your package gets your dependencies, because they need them; they do not get your devDependencies, because your test runner is your business. Get the split wrong in one direction and every consumer of your library downloads a testing framework they will never run. Get it wrong in the other and your app is missing something it needs the moment it starts.

There is a security dimension too, and it is worth naming early because it becomes real fast. Every dependency is code you did not write, running with the same permissions as code you did write, and each one brings its own dependencies. A short dependency list is not just faster to install; it is a smaller surface for somebody else's mistake. Reading a package's own package.json before adding it is a five-second habit that pays for itself.

From "date-fns" to actual code

A mental model

Follow the chain once and bare specifiers stop being mysterious. You write import { format } from "date-fns". The name has no dot and no slash at the front, so it is a bare specifier: not a file in your project. The runtime or bundler looks for a folder called date-fns inside node_modules, reads that folder's own package.json, and uses its exports or main field to decide which file inside it to load. That file is a module like any other, and it has its own imports, which get resolved the same way, all the way down.

Two consequences follow immediately. First, node_modules is generated, not authored: it is the result of npm reading your dependency list, which is why editing a file in there is pointless - the next install throws your change away - and why it is always in .gitignore. It can hold tens of thousands of files, and nothing in it is yours.

Second, if the package is not in node_modules, the import fails, and the error will say the module cannot be found rather than anything about npm. "Module not found" almost always means one of: you have not run npm install, you installed it somewhere else, or you spelled the package name differently from the way its author did.

What goes wrong

A common mistake

Treating package.json as if it were JavaScript. It is JSON: every key is quoted with double quotes, there are no comments, no trailing commas, and no expressions. A single trailing comma stops every npm command with a parse error. If you want to explain a choice, the README is the place.

Committing node_modules. It is enormous, it is machine-specific, and it is fully reproducible from package.json plus the lockfile of the next lesson. The correct thing to commit is the two small files that describe it.

Editing a file inside node_modules to fix a bug. It works until the next install, then vanishes without trace - and it works for you and nobody else. The honest routes are to report the bug upstream, or to pin the working version.

Installing without looking. npm install <name> writes to package.json and downloads that package's whole dependency tree. It is worth ten seconds on the package's page first: when it was last published, how many dependencies it drags in, and whether the thing you want is three lines you could write yourself.

Recap

Recap

A package is a folder described by a package.json. name and version identify it; main or exports says where an importer enters it; type decides whether its .js files are ES modules or CommonJS; scripts names its commands; dependencies and devDependencies list what it needs to run and what you need to work on it. A bare specifier is resolved by finding the package in node_modules and reading its own package.json. node_modules is generated from your dependency list, so it is never edited and never committed.

Worked examples

Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.

A real package.json, field by field

Small but complete, and every field here is one you will meet in the first project you open. Read it as a description of a folder: who it is, where to come in, how to run it, what it needs.

1{2  "name": "kettle-timer",3  "version": "1.4.2",4  "type": "module",5  "main": "src/index.js",6  "scripts": {7    "dev": "vite",8    "build": "vite build",9    "test": "vitest run",10    "format": "prettier --write ."11  },12  "dependencies": {13    "date-fns": "^4.1.0"14  },15  "devDependencies": {16    "vitest": "^3.2.4",17    "vite": "^7.0.0"18  }19}
  • line 2

    The name another project would type in a bare specifier: import ... from "kettle-timer".

  • line 3

    This release. Required for publishing, and the subject of the next lesson.

  • line 4

    "type": "module" means the .js files in this package are ES modules, so import/export works in them without renaming anything to .mjs.

  • line 5

    The entry point: the file an importer gets when they name the package rather than a file inside it.

  • line 6

    Everything the project knows how to do, with the exact command for each. This is the section to read first in an unfamiliar repo.

  • line 12

    Needed at run time. Anyone who installs this package gets date-fns too, because the code would break without it.

  • line 15

    Needed only while working on it. A consumer of this package never downloads vitest or vite.

  • line 17

    Note the JSON discipline: double quotes on every key, and no comma after the last entry in any block.

This one does not run herepackage.json is a JSON data file, not a JavaScript program - a bare object at the start of a program is not valid JavaScript - and the sandbox has no file system to read it from in any case. The next example shows the same manifest as data your code can inspect.

The same manifest, read as data

A package.json is text until something parses it. This runs the same JSON.parse that npm and every build tool run first, then answers the three questions you would actually ask a manifest: what is this, what runs on npm test, and what does it depend on.

1const fileText =2  '{"name":"kettle-timer","version":"1.4.2","type":"module","main":"src/index.js",' +3  '"scripts":{"test":"vitest run","build":"vite build"},' +4  '"dependencies":{"date-fns":"^4.1.0"},' +5  '"devDependencies":{"vitest":"^3.2.4"}}';6 7const manifest = JSON.parse(fileText);8 9console.log(manifest.name, manifest.version);10console.log("runs on npm test:", manifest.scripts.test);11console.log("ships with:", Object.keys(manifest.dependencies).join(", "));12console.log("build-time only:", Object.keys(manifest.devDependencies).join(", "));
  • line 1

    The bytes of the file, whitespace removed, as one string. Nothing here is JavaScript syntax yet - it is text that happens to be in JSON format.

  • line 7

    The step every tool performs before it can do anything: text in, ordinary JavaScript object out. This is module 9's JSON lesson doing real work.

  • line 10

    scripts.test is the exact command 'npm test' will run. Reading it beats guessing which test runner a project uses.

  • line 11

    Object.keys on the dependencies object gives the package names; the values are version ranges, which the next lesson decodes.

What it prints

kettle-timer 1.4.2runs on npm test: vitest runships with: date-fnsbuild-time only: vitest

Check yourself

Not scored, not stored. Getting one wrong is the useful part.

You add a test runner to a library other people will install. Which list does it belong in, and why?

A manifest is parsed and questioned. Two of these lines are counts; the third asks for something that is deliberately in the other list.

const manifest = JSON.parse(  '{"name":"kettle-timer","dependencies":{"date-fns":"^4.1.0"},"devDependencies":{"vitest":"^3.2.4","prettier":"^3.6.2"}}',);console.log(Object.keys(manifest.dependencies).length);console.log(Object.keys(manifest.devDependencies).length);console.log(manifest.dependencies.vitest);

Your file says: import { format } from "date-fns". Where does JavaScript look?

No exercise in this lesson

Why there is nothing to run here

Nothing in this lesson can be executed here: the sandbox has no npm, no file system and no module resolver, so a graded run could not install, resolve or read a package. An annotated real manifest, a runnable JSON.parse walkthrough and three checks carry the lesson instead.

Worth remembering

  • Which list does a bundler or test runner belong in, and who receives it?

    devDependencies. People who install your package receive your dependencies only - never your devDependencies.

  • Why is node_modules neither edited nor committed?

    It is generated from package.json and the lockfile. Edits vanish at the next install, and the folder is huge and machine-specific.

  • Which package.json field decides whether .js files are ES modules?

    "type". "module" means import/export; absent or "commonjs" means require. .mjs and .cjs override it per file.

Check this lesson against the source

We wrote the explanation; we did not invent the facts. This is the page that backs them.

npm Docs — package.json

Check there: name and version are described as the most important fields and required for publishing; main is the package's entry point; scripts holds commands run at points in the package's lifecycle; and devDependencies are for packages needed only for development and testing, not by consumers.

Node.js — Modules: Packages

Check there: How the "type" field, the .mjs and .cjs extensions and the "exports" field decide how a package's files are loaded.

MDN — import

Check there: Bare specifiers are resolved in node_modules rather than as paths in your project.

Prefer to read the source in a structured breakdown? Turn this doc into a breakdown

Consent version 2026-07-31.1

Cookie preferences