Skip to content

Ecosystem & Toolchain

Since Node 22.18 — and on every Node 24 and 26 release — Node strips TypeScript types itself. You only need a tool when your code uses syntax that must be transformed, not just erased. You have three main options:

flowchart TB
  Start["I need to run a .ts file"] --> Q1{"Only type annotations, interfaces, import type?"}
  Q1 -->|"yes"| Native["node file.ts -- built-in stripping, no install"]
  Q1 -->|"no -- enum, decorators, parameter properties"| Q2{"Node 26, or --experimental-transform-types?"}
  Q2 -->|"yes"| Native
  Q2 -->|"no"| Q3{"Need fast local dev loop?"}
  Q3 -->|"yes"| Tsx["tsx -- esbuild-based, milliseconds to start"]
  Q3 -->|"no, need mature config/plugins"| TsNode["ts-node"]
Reach for the built-in first; only add tsx/ts-node for syntax Node cannot erase

Option 1: built-in type stripping (Node 22.18+ / 24 — no flag)

Section titled “Option 1: built-in type stripping (Node 22.18+ / 24 — no flag)”

Node removes type annotations at load time and runs the remaining JavaScript. Nothing to install, nothing to configure — but it does not type-check and it ignores tsconfig.json:

Terminal window
# Node 22.18+ / 24 / 26 — just run the file
node src/app.ts
# Older 22.x releases needed the flag
node --experimental-strip-types src/app.ts
# Keep type-checking as a separate step (CI, pre-commit)
npx tsc --noEmit

Only erasable syntax is supported: type annotations, interfaces, type aliases, import type. Syntax that needs generated JavaScript — enum, a namespace with runtime code, parameter properties such as constructor(private x: number), import = — is rejected. Node 24 transforms those too if you pass --experimental-transform-types; in Node 26 the transform is built in and the flag is gone. TypeScript’s own --erasableSyntaxOnly option (5.8+) makes tsc reject the same constructs, so your editor warns before Node does.

Verifying “on by default” instead of trusting the flag name

Section titled “Verifying “on by default” instead of trusting the flag name”

The flag is still named --experimental-strip-types in node --help on Node 24 — that name alone would suggest you need to opt in. Checking what actually happens settles it:

Terminal window
node --help | grep -i strip
--experimental-strip-types, --no-strip-types
Type-stripping for TypeScript files.

The paired --no-strip-types flag is the tell: a --no- counterpart only exists for something that is on by default. Confirmed by actually running a .ts file both ways:

Terminal window
node strip.ts # runs fine, prints the computed value, no flag needed
node --no-experimental-strip-types strip.ts # SyntaxError: Unexpected identifier 'Point'
# node strip.ts
3
# node --no-experimental-strip-types strip.ts
SyntaxError: Unexpected identifier 'Point'

Type stripping is on by default on Node 24 for a plain .ts file with only erasable syntax — no flag required, and no ExperimentalWarning printed either. The --experimental- prefix in the flag’s name is a leftover from when it needed opting into; explicitly passing --no-experimental-strip-types (or --no-strip-types) is the only way to turn it back off.

tsx is a lightweight wrapper around esbuild. It handles all TypeScript features and starts in milliseconds:

Terminal window
# Run once
npx tsx src/app.ts
# Watch mode — auto-restart on changes
npx tsx watch src/app.ts
# Install as a dev dependency
npm install --save-dev tsx

ts-node has been the standard for years. It is heavier than tsx but has mature configuration support:

Terminal window
npm install --save-dev ts-node typescript
# Run a file
npx ts-node src/app.ts
# ESM mode (tsconfig must have "module": "NodeNext")
npx ts-node --esm src/app.ts

ESLint — catch logic errors and enforce style rules

Section titled “ESLint — catch logic errors and enforce style rules”
Terminal window
# Install ESLint with TypeScript support
npm install --save-dev eslint @eslint/js typescript-eslint
# Create a flat config (eslint.config.mjs)
# then run:
npx eslint src/
# Auto-fix fixable issues
npx eslint --fix src/

A minimal eslint.config.mjs for TypeScript:

eslint.config.mjs
// @skip-verify CLI/flag: needs @eslint/js and typescript-eslint installed as devDependencies — not part of this repo
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
);

eslint-plugin-n — Node-specific rules ESLint doesn’t know on its own

Section titled “eslint-plugin-n — Node-specific rules ESLint doesn’t know on its own”

eslint-plugin-n (formerly eslint-plugin-node) adds rules that check against Node’s actual runtime and API surface — things plain ESLint has no way to know. Running it against a deliberately buggy file shows exactly what it catches:

eslint.config.mjs
// @skip-verify CLI/flag: needs eslint-plugin-n installed as a devDependency — not part of this repo
import n from 'eslint-plugin-n';
export default [
n.configs['flat/recommended'],
];
Terminal window
npx eslint bad.js
bad.js
2:24 error Can't resolve './does-not-exist.js' in '/project' n/no-missing-require
3:13 error 'new Buffer()' was deprecated since v6.0.0. Use 'Buffer.alloc()' or 'Buffer.from()' instead n/no-deprecated-api
15:1 error Don't use process.exit(); throw an error instead n/no-process-exit
✖ 3 problems (3 errors, 0 warnings)

Three real Node-specific findings a generic ESLint config would miss entirely: n/no-missing-require catches a require() of a file that does not exist on disk, n/no-deprecated-api flags runtime APIs deprecated by Node itself (like new Buffer()), and n/no-process-exit flags process.exit() calls outside of an actual CLI entry point (it skips over silently returning from a function, which usually hides bugs rather than fixing them).

Terminal window
npm install --save-dev prettier
# Format all files
npx prettier --write .
# Check formatting without changing files (for CI)
npx prettier --check .

Prettier and ESLint focus on different things: Prettier handles formatting (spacing, quotes, semicolons), ESLint handles logic and style rules. Run both in sequence or use eslint-config-prettier to disable ESLint’s formatting rules.

Biome — one tool for both lint and format

Section titled “Biome — one tool for both lint and format”

Biome replaces ESLint + Prettier with a single fast tool written in Rust:

Terminal window
npm install --save-dev @biomejs/biome
npx biome init
# Lint and format in one pass
npx biome check --apply src/

Node built-in watch (stable since Node 22)

Section titled “Node built-in watch (stable since Node 22)”
Terminal window
# Restart on any file change — no install required
node --watch src/app.js
# Watch specific files
node --watch-path=src src/app.js
Terminal window
npm install --save-dev nodemon
# Basic usage
npx nodemon src/app.js
# With TypeScript via tsx
npx nodemon --exec "npx tsx" src/app.ts
# nodemon.json config
{
"watch": ["src"],
"ext": "ts,js,json",
"exec": "npx tsx src/app.ts"
}

Native .env loading (stable since 22.21 / 24.10)

Section titled “Native .env loading (stable since 22.21 / 24.10)”
Terminal window
# Load a .env file without dotenv
node --env-file=.env src/app.js
# Load multiple env files (later files override earlier)
node --env-file=.env --env-file=.env.local src/app.js

.env support went through an experimental period first — it shipped behind a warning in 20.6, then became stable in 22.21 and 24.10 (no ExperimentalWarning on those or later).

Inside a program, process.loadEnvFile() does the same without a flag:

Node.js
Output (verified on Node 24)
hello-from-dotenv
Terminal window
npm install dotenv
// @skip-verify CLI/flag: needs the dotenv package installed — not part of this repo
// src/app.js — load at the very top
import 'dotenv/config';
console.log(process.env.DATABASE_URL); // from .env
Terminal window
# .env — default values, committed to git (no secrets)
NODE_ENV=development
PORT=3000
LOG_LEVEL=info
# .env.local — local overrides, NOT committed (in .gitignore)
DATABASE_URL=postgres://localhost:5432/mydb
SECRET_KEY=dev-only-secret
# .env.example — template showing required vars, committed
DATABASE_URL=
SECRET_KEY=

Always add .env.local and .env*.local to .gitignore. Never commit real secrets.

node --run <script> runs a package.json script without going through npm — it starts faster and does not inject npm’s environment:

Terminal window
node --run dev # same as `npm run dev`, minus npm's startup cost
node --run test

For scripts that execute code you do not fully trust, Node 24 stabilised the flag name of the permission model. It denies file-system, child-process, and worker access unless you grant it explicitly:

Terminal window
node --permission --allow-fs-read=./data --allow-fs-write=./tmp src/app.js

Corepack ships with Node itself (verified here: corepack --version prints 0.34.5 on this course’s Node 24.13, no separate install) and manages the package manager version your project declares, the same way nvm/.nvmrc manages the Node version:

Terminal window
# Pin the package manager version in package.json
# "packageManager": "[email protected]"
corepack enable # one-time, makes `pnpm`/`yarn` resolve through corepack
pnpm install # corepack downloads and runs the exact pinned pnpm version

pnpm itself uses a content-addressable store and hard links instead of copying node_modules per project — multiple projects on the same machine share one copy of each package version on disk, and installs are usually faster than npm’s. It also enforces stricter dependency resolution by default (a package cannot require() something it did not declare as its own dependency, even if some other package hoisted it into node_modules), which surfaces “phantom dependency” bugs that npm’s flatter node_modules hides.

Docker: a production-appropriate Node image

Section titled “Docker: a production-appropriate Node image”
FROM node:24-slim
WORKDIR /app
# Only copy manifests first -- layer caching skips the install step
# when only application code changed, not dependencies.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
# node:*-slim images already ship a non-root "node" user -- use it
USER node
# Exec form (not shell form) so SIGTERM reaches the Node process directly,
# not a shell wrapping it -- required for graceful shutdown to actually run.
CMD ["node", "src/server.js"]

Three details that separate a production-appropriate image from one that merely works: npm ci --omit=dev (not npm install) — reproducible from the lockfile, and skips devDependencies entirely; USER node — runs as the image’s built-in unprivileged user instead of root; and the JSON-array (exec form) CMD — a shell-form CMD node src/server.js runs Node as a child of /bin/sh, and SIGTERM from docker stop/Kubernetes goes to the shell, not to Node, so your process.on('SIGTERM', ...) handler never fires and the orchestrator ends up waiting out the full grace period before SIGKILL.

console.log is fine for a script running on your own terminal. In a service running in a container behind a log aggregator, plain text loses the ability to filter or query by field. A structured logger (pino is the fastest common choice in the Node ecosystem) writes one JSON object per line instead:

Terminal window
npm install pino
// @skip-verify CLI/flag: needs the pino package installed — not part of this repo
import pino from 'pino';
const logger = pino();
logger.info({ userId: 42, action: 'login' }, 'user logged in');
// {"level":30,"time":...,"userId":42,"action":"login","msg":"user logged in"}

Every log line becomes a queryable record (filter by userId, alert on level >= 50) instead of a string a human has to grep. pino in particular is built to add minimal overhead per call, which matters on a hot request path where console.log itself can become a measurable cost.

{
"scripts": {
"dev": "tsx watch src/app.ts",
"build": "tsc --noEmit",
"start": "node dist/app.js",
"test": "node --test",
"lint": "eslint src/ && prettier --check .",
"format": "prettier --write . && eslint --fix src/"
}
}

Everything in this lesson is a layer you add only after confirming Node’s built-in behavior does not already cover it: type stripping before reaching for tsx, --env-file/--watch before dotenv/nodemon, node --run before assuming you need npm, node:test’s own coverage before a third-party reporter. The pattern repeats because Node’s built-ins keep absorbing what used to require a dependency — checking “does the flag name’s --no- counterpart exist” or “does --help actually mention it” is a faster way to find out than assuming last year’s answer still holds.

ChoiceBenefitCost
Built-in type strippingZero install, zero config, works the instant you run the fileNo type-checking, no support for enum/decorators/parameter properties without an extra flag
tsx/ts-nodeFull TypeScript feature support, mature ecosystemA dependency to install, pin, and keep updated
npm ci --omit=dev in DockerReproducible build from the lockfile, smaller image, faster installRequires a committed, up-to-date lockfile — npm ci fails outright if package.json and the lockfile disagree
pino/structured loggingQueryable, filterable logs; lower per-call overhead than console.log in a hot pathOutput is no longer human-readable directly in a terminal without a formatter (pino-pretty in dev)
  • “TypeScript on Node still needs tsx or ts-node in 2026” — fact: on Node 22.18+/24/26, plain type-annotation syntax runs with zero tooling; you only need a transformer for enum/decorators/parameter properties (or use --experimental-transform-types on 24).
  • “The —experimental- prefix on —experimental-strip-types means it’s opt-in” — fact: the flag’s name is a holdover; the behavior itself is on by default on Node 24, confirmed by --no-strip-types existing as a way to turn it off, not on.
  • “npm install and npm ci do the same thing, just different names” — fact: npm ci requires an existing, matching lockfile and fails if it’s out of date; it deletes node_modules first and installs exactly what’s locked — the reproducibility a Docker build needs, which npm install does not guarantee.
  • “A shell-form Dockerfile CMD and the exec-form JSON array behave the same” — fact: shell form runs your process as a child of /bin/sh, so SIGTERM from docker stop hits the shell, not your Node process — your SIGTERM handler never runs.
  • Does a generated project add ts-node, nodemon, or dotenv as dependencies without checking whether the target Node version’s built-ins (type stripping, --watch, --env-file) already cover the same need?
  • Does a Dockerfile use npm install instead of npm ci in a build step meant to be reproducible, or use shell-form CMD node server.js instead of the exec-form JSON array?
  • Does a Dockerfile skip USER node (or an equivalent non-root user), leaving the container running as root by default?
  • Is console.log used throughout a service meant to run in production, with no structured logger, making log fields impossible to query at scale?

🐛 Spot the bug

FROM node:24-slim
WORKDIR /app
COPY . .
RUN npm install
CMD node src/server.js
Show the answer

Two separate problems here. First, npm install (not npm ci) can silently update dependency versions relative to the lockfile during the Docker build — the image you build today may not match the image you build tomorrow from the identical source, and there’s no USER node line, so the container runs as root the whole time. Second, CMD node src/server.js is shell form — Docker runs it as /bin/sh -c "node src/server.js", which makes node a child process of the shell. When docker stop sends SIGTERM, it goes to the shell (PID 1), not to node, and the shell does not forward it by default — so any process.on('SIGTERM', ...) graceful-shutdown handler in the app never fires, and the container sits until Docker’s grace period expires and sends SIGKILL. The fix: RUN npm ci --omit=dev, add USER node before the CMD, and use the exec form CMD ["node", "src/server.js"] so Node receives signals directly as PID 1.

What happens when you run node src/app.ts on Node 24?
What is the key difference between Prettier and ESLint?
Which Node.js flag enables built-in watch mode (no nodemon needed)?
Which Node flag loads a .env file without the dotenv package?
node --help on Node 24 shows: --experimental-strip-types, --no-strip-types. What does the existence of the --no- counterpart tell you?
What does eslint-plugin-n's n/no-deprecated-api rule catch that plain ESLint cannot?
A Dockerfile ends with CMD node src/server.js (shell form) instead of CMD ["node", "src/server.js"] (exec form). What breaks?