React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

nodejstypescripttsxts-nodescripts

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 --verbose

What this demonstrates:

  • interface types for both the input data and the computed result
  • Typed reduce with an explicit generic argument
  • Top-level await in an ESM TypeScript file
  • Shebang pointing at tsx so 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-node and 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 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.

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, 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

Gotchas

  1. Mixing ESM and CJS. If "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.
  2. 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.
  3. __dirname in ESM TypeScript. It is only defined in CJS. In ESM, reconstruct it: const __dirname = path.dirname(fileURLToPath(import.meta.url)).
  4. Importing without .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.
  5. Assuming the runner type-checks. tsx, ts-node --transpile-only, and --experimental-strip-types all skip type-checking by default. Run tsc --noEmit in CI.
  6. --experimental-strip-types limitations. It refuses to handle enum, namespace, and parameter property syntax. Use const objects or tsx if you need those features.
  7. Forgetting @types/node. Without it, process, Buffer, and the whole node:* module namespace come up as any, defeating strict mode.

Alternatives

ToolStrengthsWeaknesses
tsxFast, ESM-friendly, watch mode, JSX supportExtra dependency
ts-nodeUses real TypeScript compiler, matureSlow startup, ESM setup is fiddly
node --experimental-strip-typesZero dependencies, bundled with Node 22+No enums, no type-check, newer flag
bun runNative TS, very fast, built-in bundlerDifferent runtime, some Node API gaps
deno runSecure by default, native TS, stdlibDifferent module resolution model
swc-nodeSWC-powered speedSmaller community, fewer features
esbuild-registerMinimal and fast require hookCJS 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));