Ecosystem & Toolchain
Running TypeScript in Node.js
Section titled “Running TypeScript in Node.js”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"] 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:
# Node 22.18+ / 24 / 26 — just run the filenode src/app.ts
# Older 22.x releases needed the flagnode --experimental-strip-types src/app.ts
# Keep type-checking as a separate step (CI, pre-commit)npx tsc --noEmitOnly 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:
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:
node strip.ts # runs fine, prints the computed value, no flag needednode --no-experimental-strip-types strip.ts # SyntaxError: Unexpected identifier 'Point'# node strip.ts3
# node --no-experimental-strip-types strip.tsSyntaxError: 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.
Option 2: tsx — fast TypeScript runner
Section titled “Option 2: tsx — fast TypeScript runner”tsx is a lightweight wrapper around esbuild. It handles all TypeScript features and starts in milliseconds:
# Run oncenpx tsx src/app.ts
# Watch mode — auto-restart on changesnpx tsx watch src/app.ts
# Install as a dev dependencynpm install --save-dev tsxOption 3: ts-node — the classic
Section titled “Option 3: ts-node — the classic”ts-node has been the standard for years. It is heavier than tsx but has mature configuration support:
npm install --save-dev ts-node typescript
# Run a filenpx ts-node src/app.ts
# ESM mode (tsconfig must have "module": "NodeNext")npx ts-node --esm src/app.tsLinters and Formatters
Section titled “Linters and Formatters”ESLint — catch logic errors and enforce style rules
Section titled “ESLint — catch logic errors and enforce style rules”# Install ESLint with TypeScript supportnpm install --save-dev eslint @eslint/js typescript-eslint
# Create a flat config (eslint.config.mjs)# then run:npx eslint src/
# Auto-fix fixable issuesnpx eslint --fix src/A minimal eslint.config.mjs for TypeScript:
// @skip-verify CLI/flag: needs @eslint/js and typescript-eslint installed as devDependencies — not part of this repoimport 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:
// @skip-verify CLI/flag: needs eslint-plugin-n installed as a devDependency — not part of this repoimport n from 'eslint-plugin-n';
export default [ n.configs['flat/recommended'],];npx eslint bad.jsbad.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).
Prettier — opinionated code formatter
Section titled “Prettier — opinionated code formatter”npm install --save-dev prettier
# Format all filesnpx 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:
npm install --save-dev @biomejs/biomenpx biome init
# Lint and format in one passnpx biome check --apply src/Watch Mode and nodemon
Section titled “Watch Mode and nodemon”Node built-in watch (stable since Node 22)
Section titled “Node built-in watch (stable since Node 22)”# Restart on any file change — no install requirednode --watch src/app.js
# Watch specific filesnode --watch-path=src src/app.jsnodemon — battle-tested watcher
Section titled “nodemon — battle-tested watcher”npm install --save-dev nodemon
# Basic usagenpx nodemon src/app.js
# With TypeScript via tsxnpx nodemon --exec "npx tsx" src/app.ts
# nodemon.json config{ "watch": ["src"], "ext": "ts,js,json", "exec": "npx tsx src/app.ts"}Environment Variables and .env Files
Section titled “Environment Variables and .env Files”Native .env loading (stable since 22.21 / 24.10)
Section titled “Native .env loading (stable since 22.21 / 24.10)”# Load a .env file without dotenvnode --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:
Output (verified on Node 24)
hello-from-dotenvdotenv — the classic
Section titled “dotenv — the classic”npm install dotenv// @skip-verify CLI/flag: needs the dotenv package installed — not part of this repo// src/app.js — load at the very topimport 'dotenv/config';
console.log(process.env.DATABASE_URL); // from .envBest practices for env files
Section titled “Best practices for env files”# .env — default values, committed to git (no secrets)NODE_ENV=developmentPORT=3000LOG_LEVEL=info
# .env.local — local overrides, NOT committed (in .gitignore)DATABASE_URL=postgres://localhost:5432/mydbSECRET_KEY=dev-only-secret
# .env.example — template showing required vars, committedDATABASE_URL=SECRET_KEY=Always add .env.local and .env*.local to .gitignore. Never commit real secrets.
node —run and the permission model
Section titled “node —run and the permission model”node --run <script> runs a package.json script without going through npm — it starts faster and does not inject npm’s environment:
node --run dev # same as `npm run dev`, minus npm's startup costnode --run testFor 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:
node --permission --allow-fs-read=./data --allow-fs-write=./tmp src/app.jscorepack and pnpm
Section titled “corepack and pnpm”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:
# Pin the package manager version in package.json# "packageManager": "[email protected]"
corepack enable # one-time, makes `pnpm`/`yarn` resolve through corepackpnpm install # corepack downloads and runs the exact pinned pnpm versionpnpm 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 itUSER 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.
Structured logging instead of console.log
Section titled “Structured logging instead of console.log”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:
npm install pino// @skip-verify CLI/flag: needs the pino package installed — not part of this repoimport 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.
package.json scripts — the glue
Section titled “package.json scripts — the glue”{ "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/" }}Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Built-in type stripping | Zero install, zero config, works the instant you run the file | No type-checking, no support for enum/decorators/parameter properties without an extra flag |
tsx/ts-node | Full TypeScript feature support, mature ecosystem | A dependency to install, pin, and keep updated |
npm ci --omit=dev in Docker | Reproducible build from the lockfile, smaller image, faster install | Requires a committed, up-to-date lockfile — npm ci fails outright if package.json and the lockfile disagree |
pino/structured logging | Queryable, filterable logs; lower per-call overhead than console.log in a hot path | Output is no longer human-readable directly in a terminal without a formatter (pino-pretty in dev) |
Common Misconceptions
Section titled “Common Misconceptions”- “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-typeson 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-typesexisting as a way to turn it off, not on. - “npm install and npm ci do the same thing, just different names” — fact:
npm cirequires an existing, matching lockfile and fails if it’s out of date; it deletesnode_modulesfirst and installs exactly what’s locked — the reproducibility a Docker build needs, whichnpm installdoes 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, soSIGTERMfromdocker stophits the shell, not your Node process — yourSIGTERMhandler never runs.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a generated project add
ts-node,nodemon, ordotenvas 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 installinstead ofnpm ciin a build step meant to be reproducible, or use shell-formCMD node server.jsinstead 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.logused 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 /appCOPY . .RUN npm install
CMD node src/server.jsShow 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.