Skip to content
aviral gupta

// I3.4 · ~36 min · Intermediate

npm: dependencies, the lock file and scripts

After this lesson you can add packages to a project with npm, use them by name in your code, and run the project’s tasks as npm scripts.

Lesson 4 of 5 in I3 Modules, Node.js and npm

You will be able to

  • Install packages with npm install, -D and -E, and say what package.json, package-lock.json and node_modules hold
  • Import an installed package by its name, and use a package’s command through a script or npx
  • Run scripts with npm run and npm test, predict pre and post scripts, and pass arguments after --
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson I3.3. package.json says "module". What does node tools.cjs print?

    // package.json
    {"type": "module"}
    
    // tools.cjs
    const path = require("node:path");
    console.log(path.basename("/docs/notes.txt"));
  2. Predict · Activity 2 of 7

    Predict before you read on. The newest tiny-case on the registry is 2.1.0. What does package.json list under "dependencies" after this command?

    npm install tiny-case
  3. Practice · Activity 3 of 7

    say-hi is only needed while you develop. Fill in the flag that saves it under devDependencies.

    npm install ____ say-hi
    npm install say-hi
  4. Practice · Activity 4 of 7

    Match each part of a project to what it holds after npm install.

  5. Practice · Activity 5 of 7

    say-hi is installed with -D and brings a command say-hi. What is the last line npm run greet prints?

    {
      "scripts": {
        "greet": "say-hi there"
      }
    }
  6. Brain teaser · Activity 6 of 7

    Brain teaser. step.js prints the word it is given. Which words does npm test print, in order?

    // package.json
    {
      "name": "app",
      "version": "1.0.0",
      "scripts": {
        "posttest": "node step.js post",
        "test": "node step.js test",
        "pretest": "node step.js pre"
      }
    }
  7. Apply · Activity 7 of 7

    Mini-task: in a new folder, run npm init -y, set "type": "module", and install a package that turns text into a slug (on your machine, for example slugify; here, tiny-case). Write slug.js, which imports the package by its name and prints a slug of its arguments, and a script "slug": "node slug.js". Run npm run slug -- My First Post. Then delete node_modules and run npm ci.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Using an installed package

This project ran npm install tiny-case, so package.json lists "tiny-case": "^2.1.0" under dependencies, and the package sits in node_modules/tiny-case. main.js imports it by its bare name. The two files of the package are shown too, so you can see what Node.js finds there: its package.json says, through "exports", which file to load.

main.js

// npm install tiny-case put the package into node_modules/tiny-case.
// A bare name (no ./) is looked up in node_modules:
import {kebab} from "tiny-case";

const titles = ["Hello World", "  ES Modules in Node  "];
for (const title of titles) {
  console.log(kebab(title));
}

node_modules/tiny-case/package.json

{"name": "tiny-case", "version": "2.1.0", "type": "module", "exports": "./index.js"}

node_modules/tiny-case/index.js

export const kebab = (text) => text.trim().toLowerCase().split(/\s+/).join("-");

Run it with

node main.js

Output

hello-world
es-modules-in-node
  • You never write files into node_modules yourself: npm install does, and it may replace them at any time.
  • A bare name such as "tiny-case" means a package; "./tiny-case.js" would mean your own file.
  • Because of "exports", only "tiny-case" itself can be imported, not "tiny-case/index.js".

Exercises

Exercise 1 of 2

Where npm saves a package

addDependency(pkg, name, version, options) returns a new package.json object with the package added, as npm install would save it: under devDependencies if options.dev is true, else under dependencies; with the exact version if options.exact is true, else with ^ in front. It must not change pkg. The starter ignores both options.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    First choose the field name from options.dev, then the range from options.exact.

  2. Hint 2

    A computed key, [field]: …, puts the new object under the chosen field.

  3. Hint 3

    Spread pkg[field] into a new object so the original is not changed.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export function addDependency(pkg, name, version, options = {}) {
  const field = options.dev ? "devDependencies" : "dependencies";
  const range = options.exact ? version : "^" + version;
  return {...pkg, [field]: {...pkg[field], [name]: range}};
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export function addDependency(pkg, name, version, options = {}) {
  const dependencies = {...pkg.dependencies, [name]: "^" + version};
  return {...pkg, dependencies};
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {addDependency} from './main.js';

const base = {name: 'app', version: '1.0.0', dependencies: {'tiny-case': '^2.1.0'}};

test('A package goes under dependencies with a caret', () => {
  const pkg = addDependency(base, 'chalk', '5.6.2');
  assert.deepEqual(pkg.dependencies, {'tiny-case': '^2.1.0', chalk: '^5.6.2'}, `dependencies: ${JSON.stringify(pkg.dependencies)}`);
});

test('With dev it goes under devDependencies', () => {
  const pkg = addDependency(base, 'say-hi', '1.0.3', {dev: true});
  assert.deepEqual(pkg.devDependencies, {'say-hi': '^1.0.3'}, `devDependencies: ${JSON.stringify(pkg.devDependencies)}`);
  assert.deepEqual(pkg.dependencies, {'tiny-case': '^2.1.0'}, `dependencies: ${JSON.stringify(pkg.dependencies)}`);
});

test('With exact the version has no caret', () => {
  const pkg = addDependency(base, 'chalk', '5.6.2', {exact: true});
  assert.equal(pkg.dependencies.chalk, '5.6.2', `chalk: ${pkg.dependencies.chalk}`);
});

test('The original object stays as it was', () => {
  addDependency(base, 'say-hi', '1.0.3', {dev: true});
  assert.deepEqual(base, {name: 'app', version: '1.0.0', dependencies: {'tiny-case': '^2.1.0'}}, `base: ${JSON.stringify(base)}`);
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 2 of 2

Which commands npm run starts

runOrder(scripts, name, args) returns the command lines npm run name -- …args would start, in order: the pre script if there is one, then the script itself with the arguments added, then the post script if there is one. The arguments go only to the script itself. If the script does not exist, throw an Error with the message Missing script: "name". The starter only returns the script.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Object.hasOwn(scripts, "pre" + name) tells you whether a pre script exists.

  2. Hint 2

    Add the arguments only to the middle command: [scripts[name], ...args].join(" ").

  3. Hint 3

    Check the name first and throw before you build the list.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export function runOrder(scripts, name, args = []) {
  if (!Object.hasOwn(scripts, name)) {
    throw new Error('Missing script: "' + name + '"');
  }
  const order = [];
  if (Object.hasOwn(scripts, "pre" + name)) order.push(scripts["pre" + name]);
  order.push([scripts[name], ...args].join(" "));
  if (Object.hasOwn(scripts, "post" + name)) order.push(scripts["post" + name]);
  return order;
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export function runOrder(scripts, name, args = []) {
  return [[scripts[name], ...args].join(" ")];
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {runOrder} from './main.js';

const scripts = {prebuild: 'node clean.js', build: 'node build.js', postbuild: 'node zip.js', test: 'node --test'};

test('A script without pre or post runs alone', () => {
  const order = runOrder(scripts, 'test');
  assert.deepEqual(order, ['node --test'], `order: ${JSON.stringify(order)}`);
});

test('Pre and post scripts run around the script', () => {
  const order = runOrder(scripts, 'build');
  assert.deepEqual(order, ['node clean.js', 'node build.js', 'node zip.js'], `order: ${JSON.stringify(order)}`);
});

test('Arguments go only to the script itself', () => {
  const order = runOrder(scripts, 'build', ['--watch']);
  assert.deepEqual(order, ['node clean.js', 'node build.js --watch', 'node zip.js'], `order: ${JSON.stringify(order)}`);
});

test('A missing script throws Missing script', () => {
  assert.throws(() => runOrder(scripts, 'biuld'), {message: 'Missing script: "biuld"'}, 'runOrder must throw for a name that is not in scripts');
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Common mistakes

Running code before npm install

import {kebab} from "tiny-case";
console.log(kebab("Hello World"));

What Node.js prints

Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'tiny-case' imported from

Why, and the fix

package.json may list tiny-case, but the package is not in node_modules: after a fresh git clone, node_modules is empty. Run npm install (or npm ci) first. The same error appears for a typo in the package name.

Importing a file inside a package

import {kebab} from "tiny-case/index.js";
console.log(kebab("Hello World"));

What Node.js prints

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './index.js' is not defined by "exports"

Why, and the fix

The package’s "exports" field lists what it lets you import, and its own index.js path is not on that list. Import the package by its name: import {kebab} from "tiny-case". Paths into a package can change in any update; the exported names are what the package promises.

A default import from a package with named exports

import kebab from "tiny-case";
console.log(kebab("Hello World"));

What Node.js prints

SyntaxError: The requested module 'tiny-case' does not provide an export named 'default'

Why, and the fix

tiny-case is an ES module that exports kebab by name and has no default export. Use braces: import {kebab} from "tiny-case". A package’s README shows how to import it; if it says import x from …, it has a default export.

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Installing writes three things

npm install tiny-case downloads the package into node_modules/tiny-case, writes "tiny-case": "^2.1.0" under dependencies in package.json, and records the exact installed tree in package-lock.json. The ^ allows later 2.x versions. With -D the package goes to devDependencies instead: tools you only need while developing, such as test runners. -E saves the exact version without ^. Keep package.json and package-lock.json in Git, never node_modules: npm install or npm ci rebuilds it.

Using a package by its name

In your code, import {kebab} from "tiny-case" uses a bare name, without ./ or a file extension. Node.js looks for it in node_modules, and the package’s own package.json says which file to load: its "exports" field also blocks paths it does not list, such as "tiny-case/index.js". A package can also bring a command, such as say-hi, which npm links into node_modules/.bin. Your shell does not look there, but npm scripts and npx do.

Scripts are named commands

The "scripts" object in package.json names commands: "test": "node --test". npm run test runs it; test and start also work without run. If prebuild or postbuild exist, npm run build runs them before and after build. Arguments after -- go to the script: npm run build -- --watch. They reach only that script, not its pre and post scripts. Scripts always run in the folder of package.json, and a missing name fails with Missing script.

Sources

Last reviewed October 5, 2026