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.
Recipe
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.
Working Example
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:
interfacetypes for both the input data and the computed result- Typed
reducewith an explicit generic argument - Top-level
awaitin an ESM TypeScript file - Shebang pointing at
tsxso the script runs directly when executable - Zero-dependency argv parsing with
node:util
Deep Dive
How It Works
None 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.
- tsx uses esbuild. Startup is ~10x faster than
ts-nodeand it supports ESM, JSX, and watch mode out of the box. - ts-node uses the real TypeScript compiler. Slower to start, but the most faithful type emission (for example, full
const enuminlining, decorator metadata). --experimental-strip-typesis 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+nodeemits real JavaScript files intooutDir. 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.
Variations
# 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"]
}
}TypeScript Notes
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, respectspackage.json"type", and forces.jsextensions on imports."moduleResolution": "NodeNext"— the only resolution mode that accurately models Node's dual-package logic."outDir": "dist"— wheretscputs the compiled JavaScript."types": ["node"]— pulls in@types/nodeglobals likeprocess,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.tsGotchas
- Mixing ESM and CJS. If
"type": "module"is set,.tsfiles compile to ESM andrequirewill throw. If you needrequire, usecreateRequire(import.meta.url)or rename to.cts. ts-nodeslow startup. The full TypeScript compiler can add 1–3 seconds per run. For iterative scripts usetsxinstead — its esbuild-backed pipeline typically starts in under 100 ms.__dirnamein ESM TypeScript. It is only defined in CJS. In ESM, reconstruct it:const __dirname = path.dirname(fileURLToPath(import.meta.url)).- Importing without
.jsextension. With"module": "NodeNext"you must write./foo.js, not./foo. The runner sees the.tsfile; the TypeScript compiler validates that the target exists. - Assuming the runner type-checks.
tsx,ts-node --transpile-only, and--experimental-strip-typesall skip type-checking by default. Runtsc --noEmitin CI. --experimental-strip-typeslimitations. It refuses to handleenum,namespace, and parameter property syntax. Useconstobjects ortsxif you need those features.- Forgetting
@types/node. Without it,process,Buffer, and the wholenode:*module namespace come up asany, defeating strict mode.
Alternatives
| 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 |
FAQs
What is the fastest way to run a single TypeScript file?
npx tsx script.ts. tsx uses esbuild and typically starts in well under 100 ms — an order of magnitude faster than ts-node.
Does tsx type-check my code at runtime?
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.
What is the difference between tsx and ts-node?
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.
What does --experimental-strip-types actually do?
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.
Can I use top-level await in a TypeScript script?
Yes, as long as the file is ESM — "type": "module" in package.json or a .mts extension — and "target" is at least ES2022.
Why do I have to write ./foo.js when the file is ./foo.ts? (gotcha)
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.
Why is ts-node so slow to start? (gotcha)
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.
Which tsconfig.json options matter most for Node scripts? (typescript)
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.
How do I type process.argv and command-line flags? (typescript)
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.
How do I run TypeScript with a shebang?
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.
Should I ship TypeScript or compiled JavaScript to production?
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.
How do I handle __dirname in an ESM TypeScript file?
Reconstruct it from import.meta.url:
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));