Skip to content

Tooling and publishing

Everything between “I have a Node project” and “other people are using it”: running TypeScript, reloading on change, managing configuration, testing without a framework, shipping a command-line tool, and publishing safely.

Node cannot execute .ts files the way tsc compiles them — types have to go somewhere. Three approaches, in rising order of ceremony.

Recent Node removes type annotations at load time and runs the result. No compiler, no config, no source maps to wire up.

Terminal window
node --experimental-strip-types app.ts # Node 22.6+
node app.ts # Node 23.6+ / 24 — on by default

The limitation is in the name: it strips, it does not compile. Syntax that needs code generation — enum, namespace, parameter properties (constructor(private x: number)), and experimentalDecorators — is rejected unless you also pass --experimental-transform-types. It also does no type checking; run tsc --noEmit separately for that.

tsconfig.json
{
"compilerOptions": {
"erasableSyntaxOnly": true // TypeScript 5.8+: error on syntax Node can't strip
}
}

The pragmatic choice for development on any Node version. Built on esbuild, so it is fast, handles the full TypeScript syntax, and resolves .ts extensions sanely.

Terminal window
npm install -D tsx
npx tsx src/index.ts
npx tsx watch src/index.ts # its own watcher

ts-node is the older equivalent, using the real TypeScript compiler. Slower, but it type-checks as it runs, which some people want.

For production, compile once with tsc (or tsup, esbuild, rollup) and run the plain JavaScript.

Terminal window
node --watch server.js # restart on change (Node 18.11+, stable from 22)
node --watch-path=./src --watch-path=./config server.js
node --watch --env-file=.env src/index.js

--watch tracks every file the process actually imported, so editing a required module triggers a restart without configuration. It is built in, has no dependencies, and is enough for most projects.

nodemon still earns its place when you need more: watching non-imported files (templates, .sql), custom extensions, debounce control, or running a non-Node command.

nodemon.json
{
"watch": ["src", "config"],
"ext": "js,ts,json,sql",
"ignore": ["src/**/*.test.ts"],
"exec": "tsx src/index.ts"
}

Configuration comes from the environment, not from committed files. The .env file is a developer convenience for populating that environment locally.

.env
DATABASE_URL=postgres://localhost:5432/dev
PORT=3000
LOG_LEVEL=debug
Terminal window
node --env-file=.env app.js # Node 20.6+ — no dependency needed
node --env-file=.env --env-file=.env.local app.js # later files win
node --env-file-if-exists=.env app.js # Node 22.9+ — don't fail if missing

Before Node 20.6, or when you need interpolation and variable expansion, use dotenv:

import 'dotenv/config'; // must run before anything that reads process.env

Validate configuration once, at startup, and crash loudly if it is wrong. A missing variable should stop the process at second zero, not produce undefined in a query string at 3 a.m.

src/config.js
function required(name) {
const value = process.env[name];
if (!value) throw new Error(`Missing required env var: ${name}`);
return value;
}
export const config = Object.freeze({
databaseUrl: required('DATABASE_URL'),
port: Number(process.env.PORT ?? 3000),
isProd: process.env.NODE_ENV === 'production',
});

Libraries like zod or envalid do the same thing with better error messages and type inference.

Node ships a test runner. Added in Node 18, stable from Node 20 — for most projects it removes the need for Jest or Vitest entirely.

src/math.test.js
import { test, describe, it, before, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { add, fetchUser } from './math.js';
test('add sums two numbers', () => {
assert.equal(add(2, 3), 5);
});
describe('add', () => {
it('handles negatives', () => {
assert.equal(add(-1, -1), -2);
});
it('supports subtests', async (t) => {
await t.test('zero is identity', () => assert.equal(add(5, 0), 5));
});
});
test('async code', async () => {
await assert.rejects(
() => fetchUser('nope'),
{ message: /not found/ },
);
});
Terminal window
node --test # discover and run test files
node --test src/ # limit to a directory
node --test --watch # rerun on change
node --test --test-name-pattern="negatives"
node --test --experimental-test-coverage # coverage report (Node 20+)
node --test --test-reporter=dot

Discovery picks up files named *.test.js, *-test.js, *_test.js, test.js, and anything inside a test/ directory (with .mjs, .cjs, and — where supported — .ts variants).

✔ add sums two numbers (0.9ms)
▶ add
✔ handles negatives (0.2ms)
✔ supports subtests (0.4ms)
▶ add (1.1ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0

node:assert/strict is the assertion library: equal (uses ===), deepEqual, throws, rejects, match, ok. The /strict import matters — the non-strict node:assert uses loose == comparison.

Mocking is built in:

import { test, mock } from 'node:test';
test('calls the logger once', () => {
const log = mock.fn();
doWork({ log });
assert.equal(log.mock.callCount(), 1);
assert.deepEqual(log.mock.calls[0].arguments, ['done']);
});
test('fake timers', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let fired = false;
setTimeout(() => { fired = true; }, 10_000);
t.mock.timers.tick(10_000);
assert.ok(fired);
});

Wire it up:

package.json
{
"scripts": {
"test": "node --test",
"test:watch": "node --test --watch",
"test:coverage": "node --test --experimental-test-coverage"
}
}

Reach for Vitest instead when you need browser-environment tests, a rich mocking API for ES modules, snapshot testing, or the Jest API your team already knows.

A command-line tool is a JavaScript file with a shebang and a bin entry.

bin/greet.js
#!/usr/bin/env node
import { parseArgs } from 'node:util';
const { values, positionals } = parseArgs({
options: {
name: { type: 'string', short: 'n', default: 'world' },
loud: { type: 'boolean', short: 'l' },
repeat: { type: 'string', short: 'r', default: '1' },
help: { type: 'boolean', short: 'h' },
},
allowPositionals: true,
});
if (values.help) {
console.log(`
Usage: greet [options] [extra...]
-n, --name <name> who to greet (default: world)
-r, --repeat <n> how many times (default: 1)
-l, --loud shout
-h, --help show this message
`);
process.exit(0);
}
let message = `Hello, ${values.name}!`;
if (values.loud) message = message.toUpperCase();
for (let i = 0; i < Number(values.repeat); i++) console.log(message);
if (positionals.length) console.log('extra:', positionals.join(', '));
package.json
{
"name": "greet-cli",
"type": "module",
"bin": { "greet": "./bin/greet.js" },
"files": ["bin"]
}
Terminal window
chmod +x bin/greet.js # required on Unix; npm sets the bit on install
npm link # symlink into your global bin for local testing
greet -n Ada --loud -r 2
# HELLO, ADA!
# HELLO, ADA!
npm uninstall -g greet-cli # undo

The essentials:

  • #!/usr/bin/env node must be the very first line, byte zero. It tells the shell to run the file with whatever node is on PATH.
  • bin maps command names to files. { "greet": "./bin/greet.js" } creates the greet command; a plain string ("bin": "./cli.js") uses the package name.
  • parseArgs (node:util, Node 18.3+) handles --flag, -f, --key=value, and -- without a dependency. For subcommands, coloured help, and prompts, commander, yargs, or clipanion do more.
  • Exit codes. 0 for success, non-zero for failure. Scripts and CI depend on it.
  • stdout for data, stderr for messages. Then greet | jq works.
// Respect the conventions people expect
if (!process.stdout.isTTY) disableColour(); // piped output
if (process.env.NO_COLOR) disableColour(); // no-color.org
process.on('SIGINT', () => process.exit(130)); // Ctrl-C

Interactive input, without a dependency:

import { createInterface } from 'node:readline/promises';
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await rl.question('Continue? (y/N) ');
rl.close();
if (answer.toLowerCase() !== 'y') process.exit(1);

An average project has hundreds of transitive dependencies, each maintained by someone you have never met, each able to run code on your machine at install time. Treat that seriously.

Terminal window
npm audit # check the tree against the advisory database
npm audit --omit=dev # only what ships to production
npm audit fix # apply fixes that stay within your semver ranges
npm audit fix --force # allow breaking major upgrades — read the diff after
npm audit signatures # verify registry signatures on installed packages

Audit output is noisy. A high-severity advisory in a build-only dependency that never touches user input is not the same risk as one in your HTTP framework. Read the advisory, decide whether the vulnerable code path is reachable, and record the decision.

When the fix lives in an unmaintained transitive dependency, force it:

package.json
{ "overrides": { "semver": "^7.6.0" } }

pnpm uses pnpm.overrides for the same purpose.

preinstall, install, and postinstall scripts run arbitrary code from every package in your tree, with your user’s permissions and your environment variables, before you have run a single line of your own code. This is how most real npm supply-chain attacks have worked.

Terminal window
npm ci --ignore-scripts # per command
.npmrc
ignore-scripts=true

Some packages genuinely need their scripts (native modules compiling, esbuild and sharp downloading platform binaries). The workable pattern is to disable scripts by default and allowlist the ones that must run.

pnpm 10+ makes this the default: dependency build scripts are blocked unless listed.

Terminal window
pnpm approve-builds # interactively allowlist packages that need to build
package.json
{
"pnpm": {
"onlyBuiltDependencies": ["esbuild", "sharp"]
}
}

The lockfile’s integrity hashes are a real defence: a tampered tarball fails verification and the install aborts. That only works if you use it.

  • Use npm ci / pnpm install --frozen-lockfile in CI, never bare install.
  • Review lockfile diffs. A pull request that changes one line of source and 300 lines of lockfile deserves a question.
  • Keep the lockfile committed, always.

Before adding one, spend thirty seconds on:

  • Do you need it? left-pad, is-odd, and is-promise were all real, widely-installed packages. String.prototype.padStart exists.
  • How big is its own tree? npm ls --all after installing, or check the package page.
  • Is it maintained? Last publish date, open issue count, number of maintainers.
  • Is the name right? Typosquatting (crossenv vs cross-env, lodahs vs lodash) is a live attack. Copy names from the real package page.
Terminal window
npm view express # metadata, maintainers, dependencies, last publish
npm ls --all | wc -l # how deep did that one install go

Node also has an experimental permission model that restricts filesystem, child-process, and worker access at the process level (--permission with --allow-fs-read / --allow-fs-write; spelled --experimental-permission on Node 20). Worth knowing about; not yet a substitute for a sandbox.

Confirm the tarball contains what you think it does:

Terminal window
npm pack --dry-run

npm prints the full file list and the total size without writing a tarball. Read that list carefully — it is exactly what the world will download.

Control the contents with the files allowlist in package.json:

package.json
{
"files": ["dist", "bin"]
}

files is an allowlist; .npmignore is a denylist. Prefer files — forgetting to exclude something leaks it, while forgetting to include something merely breaks the package loudly. If there is no files field and no .npmignore, npm falls back to .gitignore, which is how build output most often goes missing from a published package.

npm always includes package.json, README, LICENSE, and the files named by main and bin. It always excludes .git, node_modules, .npmrc, and lockfiles.

Terminal window
npm login # opens a browser; stores a token in ~/.npmrc
npm whoami # confirm
npm version patch # 1.0.0 -> 1.0.1, commits and tags (in a clean git repo)
npm version minor # 1.0.1 -> 1.1.0
npm version major # 1.1.0 -> 2.0.0
npm version 2.0.0-beta.1 # or set it explicitly
npm publish --dry-run # everything except the upload
npm publish
git push --follow-tags # push the commit and the tag npm version created

npm version runs your preversion, version, and postversion scripts, and refuses to run on a dirty working tree — a small but genuinely useful guard.

Unscoped (widget) Scoped (@acme/widget)
Name availability Must be globally unique; the good names are gone Only unique within your scope
Default visibility Public Private (requires a paid plan or an org)
Publishing publicly npm publish npm publish --access public

Scoped packages default to restricted, so the first publish of a public scoped package fails without --access public. Set it once and forget:

package.json
{ "publishConfig": { "access": "public" } }
Terminal window
npm publish --tag beta # publish WITHOUT moving `latest`
npm install my-pkg@beta # opt in
npm dist-tag ls my-pkg
npm dist-tag add my-pkg@2.0.0 latest # promote when ready

latest is what a bare npm install my-pkg gets. Publishing a pre-release without --tag makes it everyone’s default install, which is the classic way to break your users at 2 a.m.

package.json
{
"scripts": {
"build": "tsc",
"test": "node --test",
"prepublishOnly": "npm run test && npm run build"
}
}

prepublishOnly runs before npm publish but not on npm install — the correct hook for “must pass before shipping”. prepare runs on both, plus after a git-source install, which is what makes npm install github:user/repo work for a TypeScript package.

  • Enable 2FA on your npm account, and require it for publishing. Account takeover is the other main supply-chain vector.

  • Use granular access tokens in CI, scoped to specific packages and set to expire, never your personal login token.

  • Publish with provenance from CI so consumers can verify which repository and workflow built the tarball:

    Terminal window
    npm publish --provenance --access public

    This needs a supported CI provider (GitHub Actions, GitLab CI) with OIDC and id-token: write permission. npm also supports trusted publishing, which uses the same OIDC identity in place of a long-lived token.

Terminal window
npm unpublish my-pkg@1.0.1 # only within 72 hours, and only under strict conditions
npm deprecate my-pkg@"<1.0.2" "Critical bug, upgrade to 1.0.2"

Unpublishing is deliberately near-impossible — the left-pad incident in 2016 broke a large fraction of the ecosystem’s builds. The normal fix is to publish a new patch version and npm deprecate the bad one, which prints a warning on install without breaking anyone.

  • Node 22.6+ can strip TypeScript types; it does not compile enums or namespaces and never type-checks. Run tsc --noEmit separately.
  • node --watch and node --env-file remove the need for nodemon and dotenv in most projects.
  • node --test with node:assert/strict is a complete test runner, including mocks and coverage.
  • A CLI is a shebang plus a bin field; parseArgs from node:util covers ordinary flag parsing.
  • Install scripts execute third-party code before your code runs. Disable them by default and allowlist what needs them.
  • Use npm ci / --frozen-lockfile in CI so integrity hashes are actually enforced.
  • Verify the tarball with npm pack --dry-run and control it with files, not .npmignore.
  • Publish pre-releases under a dist-tag, enable 2FA, use scoped short-lived tokens, and publish with provenance from CI.