Repository navigation
ESM support: soliciting feedback #1007
Description
Activity
- addedresearchNeeds design work, investigation, or prototyping. Implementation uncertain.Needs design work, investigation, or prototyping. Implementation uncertain.
on Apr 12, 2020 TODO: turns out, users can tell the language service to include the
.jsfile extension with automatically-written imports. So we do not need to automatically add them, though we do need to check if a.jsimport might point to a.tsor.tsxfile.The option is passed to the language service in a
ts.UserPreferencesobject.
https://discordapp.com/channels/508357248330760243/640177429775777792/703301413337432114I was trying to figure out if ts-node needs to automatically switch the
"module"option between CommonJS and ESNext depending if we need to emit CommonJS or ESM. I concluded we do not want to do this. Here's an explanation anyway, in case I am proven wrong.Today, ts-node respects the tsconfig's
moduleoption. Users are required to set it appropriately. If the user incorrectly setsmoduletoESNextand then tries torequire()a TS file, they get an error because the emitted code hasimportstatements.Alternatively, we can automatically override the
moduleoption to be CommonJS when emitting forrequire()and ESNext when emitting for ESM. This allows a single tsconfig to be used for both ESM and CommonJS.After thinking about this, it doesn't make sense. Users will choose either ESM or CommonJS via their package.json file. They won't do a mix of both. Also, this would get pretty messy since we'd be doing something that doesn't match
tsc's output.Nevertheless, if we wanted to implement this:
If the
moduleoption is already correct, we can use the languageService'sgetEmitOutput()like we do today. If not, we can grab a reference to theSourceFileand transform it using the same technique astranspileModule's implementation. This allow custom emit while avoiding an expensive parse.TypeScript has an internal
sourceFileAffectingCompilerOptionsarray. If any of those options differ, aSourceFilecannot be reused. However, some are only relevant if you care about diagnostics. For swapping out themoduleflag, I thinkSourceFilecan always be reused.We have released an experimental implementation of this in v8.10.1. Please test and share your feedback here.
- pinned this issue
on May 3, 2020 - changed the title
[-]ESM support: Detailed proposal[/-][+]ESM support: Current status, proposal, soliciting feedback[/+]on May 3, 2020 - changed the title
[-]ESM support: Current status, proposal, soliciting feedback[/-][+]ESM support: Current implementation, soliciting feedback[/+]on May 3, 2020 - changed the title
[-]ESM support: Current implementation, soliciting feedback[/-][+]ESM support: soliciting feedback[/+]on May 3, 2020 Thanks @cspotcode for the release! Everything seems be working minus one snafu. Importing named exports don't seem to be working, but this may be a Node module quirk. For example in
index.ts:import { graphql } from 'graphql';
will cause a syntax error of:
SyntaxError: The requested module 'graphql' does not provide an export named 'graphql'but this can be solved by using destructuring:
import Graphql from 'graphql'; const { graphql } = Graphql;
Any way to support importing named exports in ts files?
Reacted by George MacKerron, Reinaldy Rafli, Chris Fetherston, Harsh Pandey, Albert Marashi, simon-an, Asım Tahir, nickluger, Yony, Sebastien Guillemot and 2 more@chpeters I'd guess that would be because
graphqlis actually CommonJS and not an ES module. You can read more about it here: https://nodejs.org/api/esm.html#esm_interoperability_with_commonjs. Unfortunately it'll probably be messy for a while with TypeScript since the imports syntax is overloaded to represent both CommonJS and native ES modules.Reacted by Charlie Peters, Linus Unnebäck, kolya182 and Albert MarashiUsing mocha and TypeScript with ES modules I am facing an issue and I don't quite understand it.
Running this cmd as my test cmd :
node --experimental-modules --loader ts-node/esm.mjs ./node_modules/mocha/bin/mocha --extension ts
I get this error :
import './unit/authentication.js'; ^^^^^^ SyntaxError: Cannot use import statement outside a module
What did I do wrong ?
PS: I have my
tsconfig.jsonmoduleattribute set to"ES2015", mypackage.jsontypeattribute to"module",ts-nodeinstalled locallyReacted by Dan G, swarthy, v1rtl, kolya182, Ankit Kumar, Muly Oved and Yony- Please send me a minimal reproduction and I'll be able to tell you.…On Fri, May 8, 2020, 12:01 PM Julien Collard ***@***.***> wrote: Using mocha and TypeScript with ES modules I am facing an issue and I don't quite understand it. Running this cmd as my test cmd : node --experimental-modules --loader ts-node/esm.mjs ./node_modules/mocha/bin/mocha --extension ts I get this error : import './unit/authentication.js';^^^^^^ SyntaxError: Cannot use import statement outside a module What did I do wrong ? PS: I have my tsconfig.json module attribute set to "ES2015", my package.json type attribute to "module", ts-node installed locally — You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub <#1007 (comment)>, or unsubscribe <https://github.com/notifications/unsubscribe-auth/AAC35OCOQXHWRG4OZI2UC63RQQUFZANCNFSM4MGJCWPA> .
This is my project architecture :
src |_index.ts test |_tests.ts |_unit |_authentication.ts package.json tsconfig.jsonMy
package.json:{ "name": "my-project", "version": "1.0.0", "description": "My project", "main": "lib/index", "type": "module", "files": [ "lib/**/*" ], "directories": { "test": "test" }, "scripts": { "build": "tsc", "test": "node --experimental-modules --loader ts-node/esm.mjs ./node_modules/mocha/bin/mocha --extension ts" }, "devDependencies": { "@types/chai": "^4.2.11", "@types/mocha": "^7.0.2", "@types/node": "^13.13.5", "chai": "^4.2.0", "mocha": "^7.1.2", "ts-node": "^8.10.1", "typescript": "^3.8.3" } }My
tsconfig.json:{ "compilerOptions": { "target": "ES2015", "module": "ES2015", "lib": ["es6"], "declaration": true, "outDir": "lib", "rootDir": "src", "strict": true, "noImplicitAny": true, "moduleResolution": "node", "esModuleInterop": true, "forceConsistentCasingInFileNames": true }, "exclude": [ "test/" ] }My
test/tests.ts:import './unit/authentication.js'
Typescriptis building my files right.
Thenpm run testcmd returns throw the error I wrote before.Do you need more context ?
Reacted by Andrew Bradley, lijialiang, Yan , Muly Oved and lib@NeilujD this is perfect, thanks.
It looks like, due to missing features in node's ESM support, mocha is using a hack to figure out whether a file should be loaded as ESM or CJS.
https://github.com/mochajs/mocha/blob/master/lib/esm-utils.js#L4-L23ts-node's
require()hook will need to be updated to match the error behavior of node's.jshook. When you try torequire()a TS file that should be treated as ESM, we should throw an error.At first I thought mocha could simply
import()everything, since it automatically switches to CommonJS loading as needed. However, that would require our ESM hook to be installed in order to resolve and classify .ts files. They're forced to userequire()to cater to legacyrequire()hooks.Reacted by Aksel and Yan431 remaining items
Load more actionsNote that as of Node 20 you'll need to name it
ts-loader.jsto work aroundERR_UNKNOWN_FILE_EXTENSION.node --import "data:text/javascript,import {register} from 'node:module'; import {pathToFileURL} from 'node:url'; register('ts-node/esm', pathToFileURL('./'))" my-script.tsIt works on Node.js v20.10.0. 😢
But you can create a file named
ts-loader.js:import {register} from 'node:module' import {pathToFileURL} from 'node:url' register('ts-node/esm', pathToFileURL('./'))
And then:
node --import ./ts-loader.js my-script.ts
Remember: Don't write the loader path as
(if loader file is located in your source). But make sure write it with relative path:ts-loader.js./ts-loader.js!This solution led to an error for me:
file:///<path-to-my-app/myapp.ts:2 Object.defineProperty(exports, "__esModule", { value: true }); ^ ReferenceError: exports is not defined in ES module scope at file:///<path-to-my-app/myapp.ts:2:23 at ModuleJob.run (node:internal/modules/esm/module_job:195:25) at async ModuleLoader.import (node:internal/modules/esm/loader:336:24) at async loadESM (node:internal/process/esm_loader:34:7) at async handleMainPromise (node:internal/modules/run_main:106:12)Node.js v18.19.0
Reacted by Weslley Araújo, sitaram-calvi and Artem AshI used the graphql plugin of jetbrains idea editor, and the following error occurred.
Node.js v20.11.0
Error
java.lang.Throwable: (node:79373) ExperimentalWarning: `--experimental-loader` may be removed in the future; instead use `register()`: --import 'data:text/javascript,import { register } from "node:module"; import { pathToFileURL } from "node:url"; register("ts-node/esm", pathToFileURL("./"));' (Use `node --trace-warnings ...` to show where the warning was created) Error: Cannot find module '/Users/gjq/WebstormProjects/MemberManagement/frontend/src/api/supabase' imported from /Users/gjq/WebstormProjects/MemberManagement/frontend/graphql.config.ts at finalizeResolution node_modules//ts-node@10.9.2_@types+node@20.11.30_typescript@5.4.3/node_modules//dist-raw/node-internal-modules-esm-resolve.js:366:11 at moduleResolve node_modules//ts-node@10.9.2_@types+node@20.11.30_typescript@5.4.3/node_modules//dist-raw/node-internal-modules-esm-resolve.js:801:10 at Object.defaultResolve node_modules//ts-node@10.9.2_@types+node@20.11.30_typescript@5.4.3/node_modules//dist-raw/node-internal-modules-esm-resolve.js:912:11 at node_modules//ts-node@10.9.2_@types+node@20.11.30_typescript@5.4.3/node_modules//src/esm.ts:218:35 at entrypointFallback node_modules//ts-node@10.9.2_@types+node@20.11.30_typescript@5.4.3/node_modules//src/esm.ts:168:34 at node_modules//ts-node@10.9.2_@types+node@20.11.30_typescript@5.4.3/node_modules//src/esm.ts:217:14 at addShortCircuitFlag node_modules//ts-node@10.9.2_@types+node@20.11.30_typescript@5.4.3/node_modules//src/esm.ts:409:21 at resolve node_modules//ts-node@10.9.2_@types+node@20.11.30_typescript@5.4.3/node_modules//src/esm.ts:197:12When I changed it to import
. js, this problem was solved.import {supabaseKey, supabaseUrl} from "./src/api/supabase.js";When using
require()of ESM modules in CommonJS code, Node v22 recently added support for--experimental-require-moduleflag (https://nodejs.org/en/blog/announcements/v22-release-announce#support-requireing-synchronous-esm-graphs).With this flag, it's now easy for CommonJS projects/code to slowly transition to ESM code dependency by dependency.
The only issue is that
ts-nodedoesn't seem to work with this flag properly and thus hinders adoption. Would appreciate if someone can point me so that I can contribute tots-nodeto add this flag.Reacted by Adrian, Glenn 'devalias' Grant, Dennis Kugelmann, Míla Kuchta, Dominik Rowicki, Andrii Oriekhov and Andrzej KiełtykaReacted by José Luis, Míla Kuchta and Maciej Holyszko@pksunkara i need to point out that one of the implementation details of require('es-module') is that it does not support and will never support for internal reasons top level await
to shim that and even add support for it to ts-node you can go with -r esm
to require the NPM esm package which you did install via npm i esm.the -r -i flags of node allow to import or requeire stuff before intrinsics are froozen
the esm package ads cross interop in userland. until node 22.4~ will unflag that require-module thing.
i need to point out that one of the implementation details of require('es-module') is that it does not support and will never support for internal reasons top level await
Of course, but many other use cases exist where the flag is enough.
Reacted by Frank LemanschikTo sum up.
- node-fetch v2 have a bug where it can drop transaction request silently Node.js script exits silently on some HTTPS requests node-fetch/node-fetch#1180
- node-fetch v3 does not work with cjs and ts-node
- node v18+ have native support for fetch, but it has the same bug as (1)
I am working with
ts-nodein dev,cjsto distribute module in a lerna mono repo.Quite amazed I will have now to switch to another client library
Reacted by José Luis, Maciej Holyszko and Niccolò BelliPSA: If you're still using
ts-nodefor some reason and struggle with ESM packages, try upgrading tonodejs@22.15.0+ (FYI: usingtsxis way too slow for us because it tries to bundle way too much, haven't got time to dig in).I haven't done a thorough testing yet, but upgrading node made importing
execa(ESM package) from CJS codebase just work ™️ without any code changes.For context:
- our codebase is a huge monorepo with TypeScript + yarn (pnp) + ts-node
- we invoke our scripts via
yarn ts-node:register some/script.ts- ts-node:register is set up in
package.jsonas:"ts-node:register": "cd $INIT_CWD && node -r $PROJECT_CWD/path/to/ts-node.js", ts-node.jsisrequire('ts-node').register(require('./tsconfig.tsnode.json'));tsconfig.tsnode.jsonis as follows:
- ts-node:register is set up in
{ "swc": true, "compilerOptions": { "moduleResolution": "node", "module": "commonjs" } }GH still doesn't support pagination for massive issues like this one, so I can't tell if anyone has suggested this recently, but since you've mentioned new Node features it's probably worth bringing up
--experimental-strip-types. Docs hereBetween require-module and strip-types, I've been able to write and run TS natively without additional support libraries like ts-node. The exception has been Jest tests (which still transpile to CJS with ts-jest / Babel) and the NestJS framework (which I think transpiles with Webpack?). I was able to write new tests using Node's native test runner, which even has limited support for ESM mocking, but I think that side of things is still a little immature for production use. Still, might be worth a shot especially if you're starting a new project from scratch in late 2025.
Reacted by João FerreiraReacted by José Luis@thw0rted
The problem this won't work in actual production code as it requires your ENTIRE dependency tree of the project to be devoid of "transformative" typescript, which is next to impossible for anything involving bundlers (easily 1k of transitive dependencies).Reacted by Frank Lemanschik@GabenGar node --experimental-transform-types another-example.ts
I've never really had luck trying to ship libraries in TS - as far as I can tell you always need to transpile your NPM packages down to JS, even with ts-node. (If that's wrong I'd love to read more about it?)
Anyway, this is part of why I suggested using type stripping for new projects only. The instructions I linked include a suggestion to set up your tsconfig to only allow erasable syntax, which honestly is fine - enums have plenty of other issues and are easily replaced.
try upgrading to nodejs@22.15.0
You are likely referring to all the work joyeecheung has been doing for require to work with ESM on NodeJS.
Asides from ESM mocking, ESM usability issues are pretty much resolved on the latest NodeJS versions it seems.
{ "compilerOptions": { "moduleResolution": "node", "module": "commonjs" } }I understand you are just sharing your configuration without suggesting it, but for anyone else, please don't use this configuration - it is a legacy CJS setup. If you don't understand what I mean, deep dive into "Legacy Node Resolution".
We have spent years "modernizing" our older projects to "modern" CJS configuration due to "Legacy Node Resolution" alone and all the gotchas with mocking and such...
Reacted by João FerreiraAnyway, this is part of why I suggested using type stripping for new projects only. The instructions I linked include a suggestion to set up your tsconfig to only allow erasable syntax, which honestly is fine - enums have plenty of other issues and are easily replaced.
This is fine for simple TS usage.
It's inadequate for projects using popular libraries like NestJs or TypeORM which make heavy use of decorators. And it's a shame to lose support for other transpiled TS features.
So type stripping is not a solution for everyone.
ts-nodestill continues to be a valuable tool.Reacted by James BromwellGreat point about decorators, we use Nest in several projects and there's no way around including a build step, even if it's dynamic like ts-node. I feel like the standards track for decorators has kind of stalled, too, so we may be stuck with extra tooling indefinitely.
Reacted by Simon Garner

Please use this ticket to provide feedback on our native ESM support. Your involvement is greatly appreciated to ensure the feature works on real-world projects.
Experimental warning
Node's loader hooks are EXPERIMENTAL and subject to change. ts-node's ESM support is as stable as it can be, but it relies on APIs which node can and will break in new versions of node.
When node breaks their APIs, it breaks loaders using their APIs. You have been warned!
Third-party docs: "Guide: ES Modules in NodeJS"
Someone has been maintaining a great reference document explaining how to use ts-node's ESM loader.
Guide: ES Modules in NodeJS
First-party docs
Our website explains the basics:
CommonJS vs native ECMAScript modules
Options: esm
Usage
Requirements
"module": "ESNext"or"ES2015"so that TypeScript emits import/export syntax."type": "module"in yourpackage.json, which is required to tell node that .js files are ESM instead of CommonJS. To be compatible with editors, the compiler, and the TypeScript ecosystem, we cannot name our source files.mtsnor.mjs.importstatements, or pass--experimental-specifier-resolution=nodeIdiomatic TypeScript should importfoo.tsasimport 'foo.js';TypeScript understands this.Invocation
ts-node-esm/--esm/"esm": truework by spawning a subprocess and passing it the--loaderflag.Configuration
When running
ts-node --esm,ts-node-esm, orts-nodeall CLI flags and configuration are parsed as normal. However, when passing--loader ts-node/esm, the following limitations apply:tsconfig.jsonis parsed.ts-nodemust be installed locally, not globally.npm install ts-nodeoryarn add ts-node.tsconfig will be resolved relative to
process.cwd()or toTS_NODE_PROJECT. Specify ts-node options in your tsconfig file. For details, see our docs.Use
TS_NODE_PROJECTto tell ts-node to use a specific tsconfig, and put all ts-node options into this config file.Versioning
As long as node's APIs are experimental, all changes to ESM support in ts-node, including breaking changes, will be released as minor or patch versions, NOT major versions. This conforms to semantic versioning's philosophy for version numbers lower than 1.0. Stable features will continue to be versioned as normal.
node's API change: v16.12.0, v17.0.0
Node made a breaking change in their ESM API in version 17, backported to 16.12.0. It may also be backported to 14 and 12.
This is the change: nodejs/node#37468
ts-node automatically supports both APIs, thanks to #1457. This relies on hard-coded version number checks. If/when this is backported to node 14 and 12, we will publish a new version of ts-node with the appropriate version number checks. Be sure you are always using the latest version of ts-node to avoid problems.
Note: things below this line may be out-of-date or inaccurate. These notes were used during initial implementation, but have not been updated since
Pending development work
process.argvfor config resolution?require('ts-node').esmImport(module, 'import-path')The proposal
Below is the official proposal, explaining our implementation in detail.
I am asking node's modules team questions here: nodejs/modules#351
I was reading the threads about ESM support in ts-node, e.g. #935.
The @K-FOSS/TS-ESNode implementation is unfortunately incomplete; it does not attempt to typecheck. (it uses transpileModule)
So I did some research. Below is a proposal for ESM support in
ts-node, describing the required behavior in detail.This doesn't feel like an urgent feature to me, but I like having an official proposal we can work on.
Usage
Cannot be invoked as
ts-nodebecause it requires node flags; hooks cannot be enabled at runtime. This is unavoidable.For simplicity,
--require ts-node/registercan be eliminated, becausets-node/esmautomatically does that.Alternatively, we publish an experimental
ts-node-esmentry-point which invokes anodesubprocess.Don't forget
allowJs! Affects the treatment of.jsfiles. (Not.mjsnor.cjsbecause the TS language service won't look at them)ESM hooks
Must implement ESM hooks to resolve extensionless imports to .ts files, resolve .js to .ts, classify .ts(x) and .jsx files as CJS or MJS, and compile .ts(x) and .jsx files.
resolve()hook:Match additional file extensions:
.ts,.tsx,.jsx.Resolve
.ts,.tsx, and.jsxif the import specifier says.js. Obey preferTsExts when doing this._
[Good idea?] Always ask default resolver first. If it finds something, we should not interfere.
--experimental-specifier-resolution=nodedoes not obeyrequire.extensions, unfortunately, so we can't use that.getFormathook:If the resolved file is
.ts,.tsx, or.jsx, behave as if extension was.js: use node'spackage.jsondiscovery behavior to figure out if ESM or CJS.This can be accomplished by appending
.jsto the URL path and delegating to built-ingetFormathook.transformSourcehook:Same as today's code transformer. Relies on projects to be configured correctly for
import/exportemit.Changes to existing functionality
require()hookgetFormatlogic to determine if node will treat file as CJS or ESM.require.resolvepoints to a.tsfile, we need to make the determination.require()code transformimport()calls.require('ts-node').esmImport(module, 'import-path')?ts-nodebin entry-pointts-nodeCLI does NOT need to supportimport()ing ESM.WHY? Because ESM hooks are an experimental feature which must be enabled via node CLI flag.
Thus we will be loaded via
--require, and Node is responsible for loading the entry-point, either triggering our hook or ourrequire.extensions.Allow
import()in CJSIf
"module": "commonjs", compiler transformsimport()into__importStarNo way to change this without a custom transformer, which IMO is too much complexity at this time.
Users should run their code as ESM.
If they can't do that, we can recommend the following workaround:
Emit considerations
NOTE we have not implemented the following, although initially I thought we might. Instead, we assume tsconfig is configured for either ESM or CJS as needed
We could intelligently emit both
"module": "esnext"and"module": "commonjs"depending on the classification of a file.In
transpile-onlymode this is simple. CalltranspileModulewith different options.When typechecking, we can pull
SourceFileASTs from the language service / incremental compiler.We'll need a second compiler, one for each emit format. Or we can hack it by using
transpileModulefor all ESM output.transpileModuleis incompatible with certain kinds of TS code, (can't do const enums) but it might work for a first-pass implementation.