ts-node
ts-node runs TypeScript files on Node without a build step. It hooks Node's module loading, so when something requires or imports a .ts file, ts-node hands the source to the real TypeScript compiler, gets JavaScript back, and lets Node execute it, keeping source maps so stack traces point at your .ts lines. It reads your tsconfig.json, adds its own options under a "ts-node" key, and can either typecheck as it goes (the default, and the thing that sets it apart from every esbuild-based runner) or skip checking with transpileOnly for speed. It also gives you a TypeScript REPL and register hooks that other tools such as Mocha can load.
Still the only common runner that typechecks while it executes, and it works fine on the CommonJS projects that already depend on it. For anything new, use tsx or Node's own type stripping and run tsc --noEmit separately; ts-node has not shipped a release since December 2023 and its ESM path is built on an API node has moved away from.
Use it if
- You want type errors to actually stop the process at runtime, not just in your editor; every fast alternative strips types and will happily run code that does not typecheck
- An existing CommonJS project already runs Mocha, Gulp, or a config file through node -r ts-node/register and it works, so churning to another runner buys nothing
- You want a TypeScript REPL, or need to embed one, which createRepl() in the API supports directly
- You need a custom compiler or TypeScript transformers in the execution path, which the compiler and transpiler options are built for
- Maintenance has effectively stopped. The last release, 10.9.2, is from December 2023, the repository's last push is July 2024, and 11.0.0-beta.1 has been sitting unpublished as stable since October 2023, with 198 open issues and 34 open PRs behind it
- Node itself may already do this. Node 22.6 added --experimental-strip-types and newer Node runs files with type annotations directly, so a script that only uses annotations may need no runner at all
- ESM support is fragile by the project's own admission: the README says it relies on APIs node can and will break and is not recommended for production, and node has since deprecated the --loader flag that ts-node/esm depends on in favor of module.register
- Typechecking every run is slow, and most teams end up setting transpileOnly: true to cope, at which point tsx or @swc-node/register do the same job faster and are still being released
- It does not resolve tsconfig "paths" aliases. You have to add tsconfig-paths/register as a separate require, which the README documents as intentional but is still a surprise on day one
- It is not a bundler or a watcher. Restart-on-change means bolting on nodemon or node --watch yourself
Setup reality
You install two packages, not one: ts-node plus typescript, since typescript is a peer dependency resolved from your project first and a global install falls back to the global compiler. Depending on your config you may also need @types/node and tslib. Configuration lives in three places at once and they interact: node flags must be passed to node or through NODE_OPTIONS because the ts-node binary rejects them, ts-node CLI flags must come before the entrypoint, and most options are better written into the "ts-node" block of tsconfig.json so they also apply when a tool loads ts-node/register. The two errors everyone hits are ERR_REQUIRE_ESM, from mixing an ESM dependency into CommonJS output, and ERR_UNKNOWN_FILE_EXTENSION, from running an extensionless binary such as mocha under the ESM loader. When configuration goes sideways, ts-node --showConfig and ts-node -vv tell you what it actually loaded, which is faster than guessing.
Patterns
Execute a TypeScript filerun-a-script
npm install -D ts-node typescript @types/node
npx ts-node src/script.ts
# faster: skip typechecking
npx ts-node --transpileOnly src/script.ts
npx ts-node-transpile-only src/script.tsts-node flags must come before the file. Anything after it is passed to your script, so ts-node script.ts --transpileOnly silently does nothing you wanted.
Put ts-node options in tsconfig.jsonconfigure-via-tsconfig
{
"extends": "ts-node/node16/tsconfig.json",
"ts-node": {
"transpileOnly": true,
"files": true,
"compilerOptions": { "module": "CommonJS" }
},
"compilerOptions": {
"module": "ESNext",
"target": "es2022",
"strict": true
}
}The nested ts-node.compilerOptions override the outer ones for ts-node only, which is how you keep ESM output for your bundler while running CommonJS locally. This block is the only configuration that applies when another tool loads ts-node/register.
Load ts-node inside another node processregister-hook
node -r ts-node/register src/index.ts
# reach child processes and tools too
NODE_OPTIONS="-r ts-node/register --no-warnings" node ./index.ts
# skip typechecking via the dedicated entrypoint
node -r ts-node/register/transpile-only ./index.tsnode flags cannot be given to the ts-node binary at all; either invoke node directly like this or set NODE_OPTIONS. NODE_OPTIONS also propagates into child processes and worker threads, which is usually what you want and occasionally not.
Run as native ESMrun-native-esm
// package.json: { "type": "module" }
// tsconfig.json: { "compilerOptions": { "module": "ESNext" }, "ts-node": { "esm": true } }
npx ts-node-esm src/index.ts
node --loader ts-node/esm src/index.tsThe docs call this experimental and advise against production use. --esm spawns a child process to install the loader, and node has deprecated --loader in favor of --import with module.register, so expect warnings and version-specific breakage.
Swap the transpiler for SWCuse-swc-transpiler
npm i -D @swc/core @swc/helpers
// tsconfig.json
{
"ts-node": { "swc": true }
}swc: true implies transpileOnly, so you lose typechecking entirely in exchange for a large speedup. SWC uses @swc/helpers rather than tslib, so install it if importHelpers is on.
Make tsconfig paths work at runtimeresolve-path-aliases
npm i -D tsconfig-paths
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": { "@app/*": ["src/*"] }
},
"ts-node": { "require": ["tsconfig-paths/register"] }
}ts-node deliberately does not implement paths, because tsc does not rewrite import specifiers either. Without this, an import of @app/foo compiles fine and then throws MODULE_NOT_FOUND at runtime.
Run Mocha tests written in TypeScriptrun-mocha-tests
// .mocharc.json (CommonJS)
{
"require": "ts-node/register",
"extensions": ["ts", "tsx"],
"spec": "tests/**/*.spec.ts"
}
// .mocharc.json (native ESM)
{
"loader": "ts-node/esm",
"extensions": ["ts", "tsx"]
}Mocha's binary has no file extension, which native ESM rejects with ERR_UNKNOWN_FILE_EXTENSION; ts-node 10.6 and newer include a workaround, so do not pin below that if you are on the ESM path.
Run one config file as CommonJS inside an ESM projectoverride-module-type-per-file
{
"ts-node": {
"transpileOnly": true,
"moduleTypes": {
"webpack.config.ts": "cjs",
"scripts/**/*": "cjs"
}
},
"compilerOptions": { "module": "es2020" }
}This exists because a webpack.config.ts cannot be given its own package.json to flip "type". Overridden files are compiled with isolatedModules limitations, and moduleTypes can only be set in tsconfig.json or through the API, never as a CLI flag.
Start a TypeScript REPL or evaluate an expressiontypescript-repl
npx ts-node
> const x: number = 41
> x + 1
42
npx ts-node -e 'const s: string = "hi"; console.log(s)'
npx ts-node -p -e '"Hello, world!"'
echo 'console.log(1 as number)' | npx ts-nodeTop-level await works in the REPL. With no entrypoint the tsconfig search happens relative to the current directory rather than a script, so a REPL started from a subdirectory may pick up a different config than you expect.
Write a standalone executable TypeScript scriptshebang-script
#!/usr/bin/env ts-node
// options come from tsconfig.json, not the shebang
console.log('hello from a .ts script');Passing flags in a shebang needs env -S, which is missing on older systems, so put options in tsconfig.json for portability. chmod +x the file or the shebang does nothing.
Register ts-node from codeprogrammatic-register
// bootstrap.js
require('ts-node').register({
transpileOnly: true,
project: './tsconfig.build.json',
compilerOptions: { module: 'CommonJS' },
});
require('./src/index.ts');register() installs the hook for every later require, so it must run before you import any .ts file. create() builds the same compiler service without installing hooks, which is what you want if you are embedding rather than executing.
Find out what config is actually in usedebug-configuration
npx ts-node --showConfig
npx ts-node -vv
# ts-node v10.9.2
# node v22.14.0
# compiler v5.6.3
TS_NODE_DEBUG=true npx ts-node src/index.ts--showConfig prints the merged compilerOptions plus the resolved "ts-node" block, including which tsconfig.json won. This is the fastest way to explain why one directory typechecks and another does not.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| tsx | npm | You want the same run-a-.ts-file experience, much faster, actively released, and with working ESM and CommonJS support, and you do not need typechecking at runtime. |
| @swc-node/register | npm | You want a Rust-based register hook for existing -r workflows such as Mocha, with optional typechecking in a separate process. |
| esbuild-register | npm | You want a minimal esbuild-powered require hook for scripts and config files and nothing else. |