ts-node
ts-node copied to clipboard
ts-node doesn't run ESM modules as expected, either refusing`import` statements in ts file or not being able to run `.ts` files
Search Terms
ESM import paths
Expected Behavior
I expect ts-node
to be able to run typescript code using ESM module imports. However , it either fails with
SyntaxError: Cannot use import statement outside a module
or after adding a "type": "module"
to package.json
, fails with
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for /Users/MY_PROJECT_DIR/workers/master.ts
The documentation is rather insufficient on how ts-node operations with tsconfig.json and the type
value in package.json.
Of all of target
, module
and moduleResolution
in tsconfig.json:compilerOptions
, what should be the first value that is set (as in, does target
determine module
and moduleResolution
's values, or vice versa ?)
Please provide some guidance on how this works.
Actual Behavior
Steps to reproduce the problem
This file creates basically parses the env variables and then creates a bunch of worker processes.
run with npx ts-node workers/master.ts
// workers/master.ts
import Redis from 'ioredis';
import { fork } from 'child_process';
import dotenv from 'dotenv';
import { Worker } from './worker';
import { isValid } from './../shared/lib/utils';
dotenv.config();
const streamNames: string[] = ["stream-1", "stream-2"];
const numWorkersPerStream: number = 5;
const REDIS_HOST = process.env.REDIS_HOST;
const REDIS_PORT = isValid(process.env.REDIS_PORT) ? parseInt(process.env.REDIS_PORT!, 10) : null;
if (!isValid(REDIS_HOST)) {
console.error('REDIS_HOST is not a valid environmental variable');
process.exit(1);
}
if (!isValid(REDIS_PORT)) {
console.error('REDIS_PORT is not a valid environmental variable');
process.exit(1);
}
const redisClient = new Redis({
host: REDIS_HOST!,
port: REDIS_PORT!,
});
async function createConsumerGroupIfNotExists(streamName: string, consumerGroup: string) {
try {
await redisClient.xgroup('CREATE', streamName, consumerGroup, '$', 'MKSTREAM');
console.log(`Consumer group "${consumerGroup}" created (if not already exists) for stream "${streamName}"`);
} catch (error: any) {
if (!error.message.includes('BUSYGROUP Consumer Group name already exists')) {
console.error('Error creating consumer group:', error);
}
}
}
// Create consumer groups only once
for (let i = 0; i < streamNames.length; i++) {
const streamName = streamNames[i];
const consumerGroup = `${streamName}-group`;
createConsumerGroupIfNotExists(streamName, consumerGroup);
}
// Create worker processes for each stream and group combination
for (let i = 0; i < streamNames.length; i++) {
for (let j = 0; j < numWorkersPerStream; j++) {
const workerName: string = `worker-${streamNames[i]}-${j}`;
const streamName: string = streamNames[i];
const consumerGroup: string = `${streamName}-group`;
const args: string[] = [workerName, streamName, consumerGroup, REDIS_HOST!, REDIS_PORT!.toString()];
const workerProcess = fork("./workers/worker.ts", args);
workerProcess.send("start"); // Signal the child process to start
}
}%
Minimal reproduction
Specifications
OS: macOS Sonoma 14.1.1
"ts-node": "^10.9.1"
node
version: 20.6.1
tsconfig.json
{
"compilerOptions": {
"module": "commonjs",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"ts-node": {
"compilerOptions": {
"target": "es2015",
"module": "es2015",
"moduleResolution": "nodenext"
}
}
}
I've got the same issue.
I've tried to change the target
and module
in tsconfig.json and add type:module
in package.json. Tried every combinaison but there is always something that prevents to run.
It looks like this problem has been around for a few years from the posts on github and stackoverlow.
When I run ts-node-esm ./src/index.ts
or ts-node --esm ./src/index.ts
, I get this error:
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for D:\Code\simple_node_server\src\index.ts
at new NodeError (node:internal/errors:406:5)
at Object.getFileProtocolModuleFormat [as file:] (node:internal/modules/esm/get_format:99:9)
at defaultGetFormat (node:internal/modules/esm/get_format:142:36)
at defaultLoad (node:internal/modules/esm/load:120:20)
at nextLoad (node:internal/modules/esm/hooks:833:28)
at load (D:\Biblioteka\pnpm-store\5\.pnpm\[email protected]_@[email protected][email protected]\node_modules\ts-node\dist\child\child-loader.js:19:122)
at nextLoad (node:internal/modules/esm/hooks:833:28)
at Hooks.load (node:internal/modules/esm/hooks:416:26)
at MessagePort.handleMessage (node:internal/modules/esm/worker:168:24)
at [nodejs.internal.kHybridDispatch] (node:internal/event_target:807:20) {
code: 'ERR_UNKNOWN_FILE_EXTENSION'
}
tsconfig:
"compilerOptions": {
"target": "es2016",
"module": "esnext",
"esModuleInterop": true,
}
I had the same issue on a computer with macOS, another with Windows, and another with Ubuntu, all of them running Node version 20. When testing the previous version of Node (18.18.2) on a machine with Windows and another with Ubuntu, it worked on both.
It appears that the issue arises when using version 10 of ts-node with Node version 20.
I was finally able to get this working with Node 20 and ts-node 10 by setting module
to commonjs
and target
to es6
.
Provided your project can support your Typescript compiling to CommonJS, it works well.
In the latest node.js weekly newsletter they sent an interesting gist / project stub addressing the issues - it may be helpful!
https://gist.github.com/khalidx/1c670478427cc0691bda00a80208c8cc
It is really weird that ts-node in conjuction with Node 20 is making such problems!
Also.. have you tried tsx?
This is probably a duplicate of https://github.com/TypeStrong/ts-node/issues/1997?
Hey, I had the same issue for my use case that resembles your "Steps to reproduce problem".
My case: Configuring ts-jest
for my tests. And jest
uses ts-node
to compile the typescript
test files. In those files, I am using fork
from child_process
(like in your code) to create processes and execute functions. But it was throwing this error
SyntaxError: Cannot use import statement outside a module
by pointing at the ESM import
statement declared in those processes.
Solution: register ts-node in the environments where the processes are executed.
My setup:
-
package.json
type: module
is NOT included. Just CommonJS module.
-
tsconfig.json
module
set to commonjs
and target
set as es2020
.
These two properties doesn't have to be the same: the former is responsible for the type of import statements; the latter modifies the compiled JS version.
{
"compileOnSave": true,
"compilerOptions": {
"target": "es2020" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"module": "commonjs" /* Specify what module code is generated. */,
"rootDir": "./src" /* Specify the root folder within your source files. */,
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
"strict": true /* Enable all strict type-checking options. */,
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"exclude": ["node_modules", ...]
}
-
source file
:testHelper.ts
There are 2 approaches you can take:
A. Set the args to the child proc as -r ts-node/register
which tells the node environment of the proc to be compiled with ts-node, because the proc doesn't know that by default.
workerProcess = fork(testProcessPath, {
stdio: [0, "pipe", "pipe", "ipc"],
execArgv: ["-r", "ts-node/register"],
...
});
B. Add those arguments in the test script in package.json
depending on the platform. For linux, use export NODE_OPTIONS='-r ts-node/register'
"scripts": {
...
"test": "set NODE_OPTIONS=-r ts-node/register && jest --watchAll --no-cache",
},
Solutions found from this thread #619 and the docs.
Hope that helps!