0

I'm having enormous trouble constructing a project using ts-node and ESM. There are several other questions on this topic, but I've tried solutions from all of them and gotten nowhere. It is very difficult to tell what information is out of date, with most answers involving adding additional options into the config which don't seem to make any difference in my case.

Here's the demo setup:

Node v20.4

Package.json has type: "module"

As the start script I'm currently using npx ts-node-esm src/index.ts

ts-node and Typescript are both installed locally to the project

tsconfig.json as follows:

{
    "compilerOptions": {
      "target": "ESNext",
      "baseUrl": ".",
      "esModuleInterop": true,
      "moduleResolution": "NodeNext",
      "module": "ESNext",
    },
    "types": ["node"]
}

And our demo files - src/index.ts:

import { sayHi } from "src/helpers.js";
sayHi();

And src/helpers.ts:

export function sayHi() { console.log('hi!') }

So far the error I am currently facing upon run is Unknown file extension ".ts" for src\index.ts

(I have learned that 'tsx' is likely to just work, but if there is misconfiguration on my part going on here, I would like to understand what is going wrong.)

Aside from the actual problem at hand, I also wouldn't mind some broader advice. Is this actually sensible? Everything I have read suggests new projects should be using ESM as there is no particular advantage to CommonJs (providing you can make ESM work...). Meanwhile the functionality of ts-node (or similar) seems pretty important for its role in achieving a quick development feedback loop. Given these two facts, I'm surprised I'm having so much trouble setting this up.

user3896248
  • 347
  • 1
  • 5
  • 16

2 Answers2

1

I'll try to keep this concise — you've actually asked several questions.

Config

The TS handbook section on TSConfig links to the tsconfig/bases repo, which maintains recommended base configurations for runtime environments. The appropriate base for Node version 20 is here and looks like this:

./tsconfig.node20.json:

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "display": "Node 20",
  "_version": "20.1.0",

  "compilerOptions": {
    "lib": ["es2023"],
    "module": "Node16",
    "target": "es2022",

    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "node16"
  }
}

You can use it like this (and at the same time explicitly set the module type and any other modifications):

./tsconfig.json:

{
  "extends": "./tsconfig.node20.json",
  "compilerOptions": {
    "module": "esnext",
    "outDir": "./dist"
  },
  "include": [
    "./src/**/*"
  ]
}

For completeness, the project package file looks like this in my reproduction:

./package.json:

{
  "name": "so-76725253",
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "compile": "tsc",
    "dev": "ts-node --esm src/index.ts"
  },
  "license": "MIT",
  "devDependencies": {
    "@types/node": "^20.4.2",
    "ts-node": "^10.9.1",
    "typescript": "^5.1.6"
  }
}

ES module specifiers

Your ./src/index.ts file starts out with this import statement:

import { sayHi } from "src/helpers.js";

ES module specifiers should be fully-qualified paths to JavaScript module files relative to the module in which the specifier is written. That is — unless you're using a build tool or bundler which rewrites your specifiers. Or you're using an import map or equivalent runtime specifier-resolving feature — e.g. Node.js has algorithmic module specifier resolution. In general, module resolution is not a simple subject, but for the purposes of the code you've shown, it should be a fully-qualified, relative path.

So — because helpers.ts is a sibling file in the src directory — the specifier should be written this way:

./src/index.ts:

import { sayHi } from "./helpers.js";

sayHi();

Your helpers module looks fine:

./src/helpers.ts:

export function sayHi() {
  console.log("hi!");
}

Now, running the npm script dev (defined in ./package.json) results in success:

% npm run dev

> so-76725253@0.1.0 dev
> ts-node --esm src/index.ts

hi!

I've covered a lot of the TypeScript-related environment aspects in greater depth in another answer while responding to a question with similar context. You might find it useful to review that post.

jsejcksn
  • 27,667
  • 4
  • 38
  • 62
  • Thanks for the clarification - I was aware of the bases but hadn't realised that they are actually fairly ergonomic to use and there's nothing too arcane in there. On imports, I was going to ask, is there any magic that can let us write absolute imports while staying in ESM? Or would it be more recommended to get used to keeping everything relative? – user3896248 Jul 25 '23 at 00:19
  • [^](https://stackoverflow.com/questions/76725253/ts-node-and-esm-unknown-file-extension-ts/76758731#comment135323291_76725718) @user3896248 If you have a new question to ask, please [ask a new question](https://stackoverflow.com/questions/ask) (after first searching for duplicates). I can't make a recommendation without more info, but using relative paths is the least complicated approach. I already shared a link about module resolution in the answer — I encourage you to read that documentation and [the analog in the Node.js docs](https://nodejs.org/api/packages.html#subpath-imports). – jsejcksn Jul 25 '23 at 00:34
0

It wasn't a misconfiguration as such -- ts-node with esm just doesn't play nicely with node 20 at this very specific instance in time. Using node --loader ts-node/esm src/index.ts does in fact work, as per https://github.com/TypeStrong/ts-node/issues/1997 , as would going back to the LTS release.

user3896248
  • 347
  • 1
  • 5
  • 16