Running TypeScript Scripts
Different ways to execute TypeScript files without a manual compile step - tsx, ts-node, Node 22's native type stripping, and the traditional tsc + node pipeline.
Search across all documentation pages
Different ways to execute TypeScript files without a manual compile step - tsx, ts-node, Node 22's native type stripping, and the traditional tsc + node pipeline.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card - copy-paste ready.
# Fastest modern option - tsx (esbuild under the hood)
npx tsx script.ts
# Watch mode with tsx
npx tsx watch script.ts
# Classic option - ts-node
npx ts-node script.ts
# Native Node.js type stripping (Node 22.6+)
node --experimental-strip-types script.ts
# Compile-then-run (best for production)
npx tsc
node dist/script.js// package.json
{
"name": "ts-cli",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/script.ts",
"start": "tsx src/script.ts",
"build": "tsc",
"start:prod": "node dist/script.js"
},
"devDependencies": {
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"@types/node": "^22.0.0"
}
}// tsconfig.json - minimal Node.js script config
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"]
}When to reach for this: Any script where you want typed argv, typed config, typed data models, or shared types with your application code.
A typed CLI that reads a JSON file and prints a summary.
// src/summarize.ts
#!/usr/bin/env tsx
import { readFile } from 'node:fs/promises';
import { parseArgs } from 'node:util';
interface Order {
id: string;
total: number;
status: 'paid' | 'pending' | 'failed';
}
interface Summary {
count: number;
revenue: number;
paid: number;
}
const { values, positionals } = parseArgs({
options: {
verbose: { type: 'boolean', short: 'v' },
},
allowPositionals: true,
});
const file = positionals[0];
if (!file) {
console.error('Usage: summarize <orders.json> [--verbose]');
process.exit(1);
}
const raw = await readFile(file, 'utf8');
const orders = JSON.parse(raw) as Order[];
const summary: Summary = orders.reduce<Summary>(
(acc, o) => ({
count: acc.count + 1,
revenue: acc.revenue + o.total,
paid: acc.paid + (o.status === 'paid' ? 1 : 0),
}),
{ count: 0, revenue: 0, paid: 0 },
);
if (values.verbose) {
console.log(JSON.stringify(summary, null, 2));
} else {
console.log(`orders=${summary.count} revenue=${summary.revenue} paid=${summary.paid}`);
}# Run it
npx tsx src/summarize.ts ./orders.json --verboseWhat this demonstrates:
interface types for both the input data and the computed resultreduce with an explicit generic argumentawait in an ESM TypeScript filetsx so the script runs directly when executablenode:utilNone of the "TypeScript runners" actually type-check at runtime. They all strip types and hand the resulting JavaScript to the V8 engine. The differences are in how - and how fast - that stripping happens.
ts-node and it supports ESM, JSX, and watch mode out of the box.const enum inlining, decorator metadata).--experimental-strip-types is Node.js's built-in loader (stable flag in Node 22.6+, unflagged in Node 23+). It deletes type annotations via a lightweight transform - it does not compile enums or namespaces, and it does not type-check.tsc + node emits real JavaScript files into outDir. This is what you ship to production because it removes the runtime runner dependency entirely.Because none of these runners type-check, pair them with tsc --noEmit in CI or a pre-commit hook to guarantee type safety.
# tsx vs ts-node - different flags, similar shape
npx tsx script.ts --flag
npx ts-node script.ts --flag
# tsx with watch mode and a glob
npx tsx watch --clear-screen=false src/**/*.ts
# Native Node.js type stripping
node --experimental-strip-types script.ts
# Compile-then-run for production Docker images
tsc && node dist/script.js
# Run tsx via a shebang
# #!/usr/bin/env -S npx tsx// Stricter tsconfig.json for scripts
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"types": ["node"]
}
}For Node.js scripts the most important compiler options are:
"target": "ES2022" - Node 18+ supports it natively, so no polyfills."module": "NodeNext" - matches Node's actual module resolution, respects package.json "type", and forces .js extensions on imports."moduleResolution": "NodeNext" - the only resolution mode that accurately models Node's dual-package logic."outDir": "dist" - where tsc puts the compiled JavaScript."types": ["node"] - pulls in @types/node globals like process, Buffer, and __dirname (CJS only).With NodeNext, import specifiers must include the final .js extension even though you are writing .ts. This feels strange the first time:
import { loadOrders } from './orders.js'; // refers to orders.ts"type": "module" is set, .ts files compile to ESM and require will throw. If you need require, use createRequire(import.meta.url) or rename to .cts.ts-node slow startup. The full TypeScript compiler can add 1–3 seconds per run. For iterative scripts use tsx instead - its esbuild-backed pipeline typically starts in under 100 ms.__dirname in ESM TypeScript. It is only defined in CJS. In ESM, reconstruct it: const __dirname = path.dirname(fileURLToPath(import.meta.url))..js extension. With "module": "NodeNext" you must write ./foo.js, not ./foo. The runner sees the .ts file; the TypeScript compiler validates that the target exists.tsx, ts-node --transpile-only, and --experimental-strip-types all skip type-checking by default. Run tsc --noEmit in CI.--experimental-strip-types limitations. It refuses to handle enum, namespace, and parameter property syntax. Use const objects or tsx if you need those features.@types/node. Without it, process, Buffer, and the whole node:* module namespace come up as any, defeating strict mode.| Tool | Strengths | Weaknesses |
|---|---|---|
tsx | Fast, ESM-friendly, watch mode, JSX support | Extra dependency |
ts-node | Uses real TypeScript compiler, mature | Slow startup, ESM setup is fiddly |
node --experimental-strip-types | Zero dependencies, bundled with Node 22+ | No enums, no type-check, newer flag |
bun run | Native TS, very fast, built-in bundler | Different runtime, some Node API gaps |
deno run | Secure by default, native TS, stdlib | Different module resolution model |
swc-node | SWC-powered speed | Smaller community, fewer features |
esbuild-register | Minimal and fast require hook | CJS focus, not great for ESM |
npx tsx script.ts. tsx uses esbuild and typically starts in well under 100 ms - an order of magnitude faster than ts-node.
No. tsx strips types with esbuild and runs the JavaScript. Pair it with tsc --noEmit in CI or a pre-commit hook if you want guarantees.
tsx is an esbuild-backed loader optimized for speed and ESM support. ts-node uses the real TypeScript compiler, which is slower but emits exactly what tsc would emit - including enums and decorator metadata.
It is a Node.js flag (stable in Node 22.6+) that removes TypeScript type annotations on the fly before handing the file to V8. It does not compile enums or namespaces, and it does not type-check.
Yes, as long as the file is ESM - "type": "module" in package.json or a .mts extension - and "target" is at least ES2022.
With "module": "NodeNext" TypeScript enforces Node's actual ESM resolution rules, which require the exact file extension as it appears at runtime. At runtime that file will be ./foo.js (after compilation) or the loader will map ./foo.js back to ./foo.ts - either way the import must end in .js.
Because it spins up the real TypeScript compiler on every run. Use ts-node --transpile-only to skip type-checking, or switch to tsx, which uses esbuild.
target: ES2022, module: NodeNext, moduleResolution: NodeNext, strict: true, and types: ["node"]. Together these align TypeScript's view of the world with Node.js's actual module resolution and give you full typings for built-ins.
process.argv is already typed as string[]. For flags, use parseArgs from node:util - its generic return type narrows to your options object, so values.verbose comes back as boolean | undefined.
Use #!/usr/bin/env -S npx tsx or install tsx globally and use #!/usr/bin/env tsx. The -S flag lets env split the arguments so npx tsx is treated as a single command.
Compiled JavaScript. Running tsc during your build produces plain .js files in dist/, eliminating the runner dependency and making startup as fast as possible. Use tsx or ts-node for development only.
Reconstruct it from import.meta.url:
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥