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.
Running TypeScript
Section titled “Running TypeScript”Node cannot execute .ts files the way tsc compiles them — types have to go somewhere. Three approaches, in rising order of ceremony.
Node’s built-in type stripping
Section titled “Node’s built-in type stripping”Recent Node removes type annotations at load time and runs the result. No compiler, no config, no source maps to wire up.
node --experimental-strip-types app.ts # Node 22.6+node app.ts # Node 23.6+ / 24 — on by defaultThe 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.
{ "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.
npm install -D tsxnpx tsx src/index.tsnpx tsx watch src/index.ts # its own watcherts-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.
Watch mode
Section titled “Watch mode”node --watch server.js # restart on change (Node 18.11+, stable from 22)node --watch-path=./src --watch-path=./config server.jsnode --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.
{ "watch": ["src", "config"], "ext": "js,ts,json,sql", "ignore": ["src/**/*.test.ts"], "exec": "tsx src/index.ts"}Configuration and environment
Section titled “Configuration and environment”Configuration comes from the environment, not from committed files. The .env file is a developer convenience for populating that environment locally.
DATABASE_URL=postgres://localhost:5432/devPORT=3000LOG_LEVEL=debugnode --env-file=.env app.js # Node 20.6+ — no dependency needednode --env-file=.env --env-file=.env.local app.js # later files winnode --env-file-if-exists=.env app.js # Node 22.9+ — don't fail if missingBefore Node 20.6, or when you need interpolation and variable expansion, use dotenv:
import 'dotenv/config'; // must run before anything that reads process.envValidate 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.
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.
The built-in test runner
Section titled “The built-in test runner”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.
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/ }, );});node --test # discover and run test filesnode --test src/ # limit to a directorynode --test --watch # rerun on changenode --test --test-name-pattern="negatives"node --test --experimental-test-coverage # coverage report (Node 20+)node --test --test-reporter=dotDiscovery 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 0node: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:
{ "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.
Building a CLI
Section titled “Building a CLI”A command-line tool is a JavaScript file with a shebang and a bin entry.
#!/usr/bin/env nodeimport { 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(', '));{ "name": "greet-cli", "type": "module", "bin": { "greet": "./bin/greet.js" }, "files": ["bin"]}chmod +x bin/greet.js # required on Unix; npm sets the bit on installnpm link # symlink into your global bin for local testinggreet -n Ada --loud -r 2# HELLO, ADA!# HELLO, ADA!npm uninstall -g greet-cli # undoThe essentials:
#!/usr/bin/env nodemust be the very first line, byte zero. It tells the shell to run the file with whatevernodeis onPATH.binmaps command names to files.{ "greet": "./bin/greet.js" }creates thegreetcommand; 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, orclipaniondo more.- Exit codes.
0for success, non-zero for failure. Scripts and CI depend on it. - stdout for data, stderr for messages. Then
greet | jqworks.
// Respect the conventions people expectif (!process.stdout.isTTY) disableColour(); // piped outputif (process.env.NO_COLOR) disableColour(); // no-color.orgprocess.on('SIGINT', () => process.exit(130)); // Ctrl-CInteractive 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);Security and the supply chain
Section titled “Security and the supply chain”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.
Auditing
Section titled “Auditing”npm audit # check the tree against the advisory databasenpm audit --omit=dev # only what ships to productionnpm audit fix # apply fixes that stay within your semver rangesnpm audit fix --force # allow breaking major upgrades — read the diff afternpm audit signatures # verify registry signatures on installed packagesAudit 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:
{ "overrides": { "semver": "^7.6.0" } }pnpm uses pnpm.overrides for the same purpose.
Install scripts
Section titled “Install scripts”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.
npm ci --ignore-scripts # per commandignore-scripts=trueSome 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.
pnpm approve-builds # interactively allowlist packages that need to build{ "pnpm": { "onlyBuiltDependencies": ["esbuild", "sharp"] }}Lockfile integrity
Section titled “Lockfile integrity”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-lockfilein CI, never bareinstall. - 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.
Choosing dependencies
Section titled “Choosing dependencies”Before adding one, spend thirty seconds on:
- Do you need it?
left-pad,is-odd, andis-promisewere all real, widely-installed packages.String.prototype.padStartexists. - How big is its own tree?
npm ls --allafter installing, or check the package page. - Is it maintained? Last publish date, open issue count, number of maintainers.
- Is the name right? Typosquatting (
crossenvvscross-env,lodahsvslodash) is a live attack. Copy names from the real package page.
npm view express # metadata, maintainers, dependencies, last publishnpm ls --all | wc -l # how deep did that one install goNode 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.
Publishing a package
Section titled “Publishing a package”Before you publish
Section titled “Before you publish”Confirm the tarball contains what you think it does:
npm pack --dry-runnpm 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:
{ "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.
Publishing
Section titled “Publishing”npm login # opens a browser; stores a token in ~/.npmrcnpm 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.0npm version major # 1.1.0 -> 2.0.0npm version 2.0.0-beta.1 # or set it explicitly
npm publish --dry-run # everything except the uploadnpm publishgit push --follow-tags # push the commit and the tag npm version creatednpm version runs your preversion, version, and postversion scripts, and refuses to run on a dirty working tree — a small but genuinely useful guard.
Scoped vs unscoped
Section titled “Scoped vs unscoped”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:
{ "publishConfig": { "access": "public" } }Dist-tags and pre-releases
Section titled “Dist-tags and pre-releases”npm publish --tag beta # publish WITHOUT moving `latest`npm install my-pkg@beta # opt innpm dist-tag ls my-pkgnpm dist-tag add my-pkg@2.0.0 latest # promote when readylatest 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.
Build before publish
Section titled “Build before publish”{ "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.
Hardening the release
Section titled “Hardening the release”-
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 publicThis needs a supported CI provider (GitHub Actions, GitLab CI) with OIDC and
id-token: writepermission. npm also supports trusted publishing, which uses the same OIDC identity in place of a long-lived token.
Fixing mistakes
Section titled “Fixing mistakes”npm unpublish my-pkg@1.0.1 # only within 72 hours, and only under strict conditionsnpm 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.
Key points
Section titled “Key points”- Node 22.6+ can strip TypeScript types; it does not compile enums or namespaces and never type-checks. Run
tsc --noEmitseparately. node --watchandnode --env-fileremove the need fornodemonanddotenvin most projects.node --testwithnode:assert/strictis a complete test runner, including mocks and coverage.- A CLI is a shebang plus a
binfield;parseArgsfromnode:utilcovers 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-lockfilein CI so integrity hashes are actually enforced. - Verify the tarball with
npm pack --dry-runand control it withfiles, not.npmignore. - Publish pre-releases under a dist-tag, enable 2FA, use scoped short-lived tokens, and publish with provenance from CI.