skip to content

Running a script with tsx succeeds, but running the same file with ts-node fails with a type error before anything executes. What is different about how the two tools run a TypeScript file?

level: juniorimportance: should knowfreq 52%

answer

  1. one runs the compiler, one runs esbuild
  2. check-then-run versus strip-and-run
  3. the fast one never reports a type error
  4. a flag turns checking off in the slower one

basics

~20 s

ts-node type-checks with the real TypeScript compiler before running, so a type error stops it. tsx uses esbuild to strip types with no checking, so it runs whatever parses. ts-node's --transpileOnly flag makes it behave like tsx.

solid answer

~40 s

They sit on opposite sides of the check/emit split. `ts-node` loads the actual TypeScript compiler, builds a program from your `tsconfig.json` and type-checks before executing, so a type error surfaces as a failure at startup — nothing runs. `tsx` uses esbuild to strip the type syntax and hand the JavaScript to Node; there is no checker in the pipeline, so anything that parses will run, type errors included. That is why tsx starts much faster on a large project. If you want ts-node's speed without its checking, run it with `--transpileOnly`, and if you want tsx's speed with a safety net, run `tsc --noEmit` as a separate step. The choice is feedback-at-startup versus startup latency, not correctness of the emitted code.

code

bash · 11 lines
bash
# Type-checks the program first; a type error stops it before execution.
npx ts-node scripts/seed.ts

# Same file, checking skipped: erase types and run.
npx ts-node --transpileOnly scripts/seed.ts

# esbuild-based: strips types, never checks.
npx tsx scripts/seed.ts

# The gate you keep either way.
npx tsc --noEmit

go deeper

for a junior

Be able to say which tool checks types and which only strips them, and that a script running successfully does not mean it type-checks. Knowing the --transpileOnly escape hatch is a bonus.

for a middle

Explain the mechanism: one loads the TypeScript compiler API and builds a program from tsconfig.json, the other hands files to esbuild. Connect it to the same check-versus-emit split that a bundler-based build has.

for a senior

Show you would place the guarantee deliberately — fast runner for the dev loop, one blocking tsc --noEmit in CI — and spot that adding --transpileOnly can silently delete a repo's only gate.

for a principal

Own the standard: which runner the team uses everywhere, where the single enforcement point lives, and how you keep local and CI behaviour identical so nobody's script passes only because of the runner they happened to pick.

## The same split, applied to running scripts TypeScript's compiler does two separable jobs — check the types, and emit JavaScript with the types erased. Every tool that runs a `.ts` file directly has to decide which of those jobs it performs. That single decision explains the entire difference between `ts-node` and `tsx`. ## ts-node: checking is the default `ts-node` registers a Node loader that hands each `.ts` file to the real TypeScript compiler API. It reads your `tsconfig.json`, builds a program, and reports diagnostics. By default it does check, so a mistake like this stops the process before your code runs: ```ts const port: number = process.env.PORT; // string | undefined is not number startServer(port); ``` You get a `TSError` naming the file and line, and `startServer` is never called. That is a feature when the script is something you run in CI or in production-adjacent tooling: the type gate travels with execution. It has two escape hatches. `--transpileOnly` (equivalently the `TS_NODE_TRANSPILE_ONLY` environment variable) skips checking and only erases types, and `--swc` swaps the transform for swc, which is much faster and also does not check. ## tsx: stripping only, by design `tsx` uses esbuild for the transform. esbuild has no type checker, so `tsx` never reports a type error — it converts the file to JavaScript and runs it. Startup is dramatically faster on a large codebase because nothing has to resolve the type graph across your imports and `.d.ts` files. The consequence is exactly the same one that applies to a bundler-based build: a successful run tells you nothing about whether the types check. If you use tsx for local scripts and dev servers, correctness has to come from a separate `tsc --noEmit` step in CI. ## Which failure you actually see The distinction is visible in *when* and *how* things fail: - ts-node with checking: fails at startup, with a compiler diagnostic, and no code runs. - tsx, or ts-node `--transpileOnly`: starts, runs, and either works or fails at runtime with a plain JavaScript error such as `undefined is not a function` — the same error you would have got from the equivalent untyped JavaScript. This is why "it works with tsx but not ts-node" is almost never a bug in either tool. The type error is real; one tool reports it and the other does not look. ## Choosing between them For an interactive dev loop, checking on every restart is often wasted work — your editor is already showing you the same errors continuously from the same `tsconfig.json`, so paying for a whole program build per restart buys little. For a one-shot script executed by CI or an operator, checking at startup is cheap insurance. A sensible default in most repos: run the fast, non-checking tool during development, and rely on a blocking `tsc --noEmit` job as the actual gate. That way the fast path stays fast and the guarantee lives in exactly one place instead of being an accident of which runner someone happened to use. ## Things that surprise people Because tsx never consults the checker, it also never enforces your strictness settings at runtime — those settings only matter to whatever runs the checker. And because ts-node's checking is on by default, a project that adds `--transpileOnly` for speed has quietly removed a gate; if that was the only place types were checked, the repo now has no gate at all. Both tools erase types identically for execution purposes; the only thing at stake is whether anyone looked at the types first.

  • If tsx never type-checks, why do people still keep tsconfig.json in a tsx-only project?
    Because the editor's language service, `tsc --noEmit`, and most other tooling read it — it is still the definition of what type-correct means for the repo, plus module resolution and lib settings for checking. What tsx ignores is only the checking side; the file has not stopped being the source of truth.
  • How would you get ts-node's guarantee without paying the startup cost on every restart?
    Run the fast, non-checking path for the dev loop and keep `tsc --noEmit` as a separate command — in a watch process alongside the server, and as a blocking CI job. You then pay for checking once continuously rather than once per restart, and the gate is enforced in one explicit place.
  • A teammate adds --transpileOnly to the ts-node command in CI to speed it up. What do you check first?
    Whether that ts-node invocation was the repo's only type-check. If there is no separate `tsc --noEmit` job, the change silently deletes the gate and type errors will start merging. If a real gate exists elsewhere, the flag is a fine speed win.

saying these in an interview costs you the question

  • Believing tsx reports type errors like a compiler
  • Saying ts-node and tsx differ only in speed
  • Assuming a script that runs must therefore type-check
  • Thinking --transpileOnly makes checking faster rather than skipping it

context