microsoft/TypeScript

Concerns with TypeScript 4.5's Node 12+ ESM Support

DanielRosenwasser opened this issue · 170 comments

For TypeScript 4.5, we've added a new module mode called node12 to better-support running ECMAScript modules in Node.js. Conceptually, the feature is simple - for whatever Node.js does, either match it or overlay something TypeScript-specific that mirrors thes same functionality. This is what TypeScript did for our initial Node.js support (i.e. --moduleResolution node and --module commonjs); however, the feature is much more expansive, and over the past few weeks, a few of us have grown a bit concerned about the complexity.

I recently put together a list of user scenarios and possibly useful scripts for a few people on the team to run through, and we found a few sources of concerns.

  • Bugs
  • UX
  • Ecosystem
  • User Guidance

Bugs

Most complex software ships with a few bugs. Obviously, we want to avoid them, but the more complex a feature is, the harder it is to cover all the use-cases. As we get closer to our RC date, do we feel confident that what we're shipping has as few blocking bugs as possible?

I would like to say we're close, but the truth is I have no idea. It feels like we'll have to keep trying the features for a bit until we don't run into anything - but we have less than 3 weeks before the RC ships.

Here's a few surprising bugs that need to get fixed before I would feel comfortable shipping node12 in stable.

  • Code changes breaking module resolution
  • Auto-imports not working: #46332 (technically present in CommonJS)
  • resolveJsonModule can't be used with node12: #46362
  • No errors on extensionless imports from .ts and .tsx files in node12 (unfiled, reported by @andrewbranch)
  • Strange errors under pnpm (unfiled, reported by @DanielRosenwasser)
  • package.json changes in packages not tracked (unfiled, reported by @DanielRosenwasser)

UX Concerns

In addition to bugs we found, there are just several UX concerns. Package authoring is already a source of confusion in the TypeScript ecosystem. It's too easy to accidentally shoot yourself in the foot as a package author, and it's too hard to correctly consume misconfigured packages. The node12 mode makes this a whole lot worse. Two filed examples of user confusion:

  • It's too hard to tell whether you're in an ESM or a CJS file: #46408
  • The export field is confusing to configure and diagnose: #46334
  • Poor errors on extensionless imports: #46152

While there might be a lot of "working as intended" behavior here, the question is not about whether it works, but how it works - how do we tell users when something went wrong. I think the current implementation leaves a lot of room for some polish.

But there are some questions about this behavior, and we've had several questions about whether we can simplify it. One motivating question I have is:

When a user creates a new TypeScript project with this mode, when would they not want "type": "module"? Why? Should that be required by default?

We've discussed this a bit, and it seems a bit strange that because we want to cover the "mixed mode" case so much, every Node 12+ user will have to avoid this foot-gun.

I would like to see a world where we say "under this mode, .ts files must be covered by a "type": "module"". .cts can do their own CommonJS thing, but they need to be in a .cts file.

Another motivating question is:

Why would I use node12 today instead of nodenext?

Node 14.8 added top-level await, but Node 12 doesn't have it. I think this omission is enough of a wart that starting at Node 12 is the wrong move.

Ecosystem

The ecosystem is CONFUSING here. Here's a taste of what we've found:

  • ts-node, Webpack, and Vite don't like imports with .js extensions, but TypeScript expects them. Not all of these can be configured with a plugin.
  • ts-node, Webpack, and Vite, and Deno are fine with .ts extensions, but TypeScript doesn't allow them!
  • Many packages that ship types have started supporting export fields, but don't have a types sub-field within export (e.g. RxJS, Vue 3).
  • Many packages have started supporting export fields, but their @types package might not reflect that.

The last two can be easily fixed over time, though it would be nice to have the team pitch in and help prepare a bit here, especially because it's something that affects our tooling for JavaScript users as well (see #46339)

However, the first two are real issues with no obvious solutions that fall within our scope.

There's also other considerations like "what about import maps?" Does TypeScript ever see itself leveraging those in some way, and will package managers ever support generating them?

Guidance

With --moduleResolution node, it became clear over time that everyone should use this mode. It made sense for Node.js apps/scripts, and it made sense for front-end apps that were going to go through a bundler. Even apps that didn't actually load from node_modules could take advantage of @types in a fairly straightforward way.

Now we have an ecosystem mismatch between Node.js and bundlers. No bundler is compatible with this new TypeScript mode (and keep in mind, back-end code also occasionally uses a bundler).

Here's some questions I wouldn't know how to answer confidently:

  • Is our guidance to always use this mode for plain Node.js apps, and let tools "catch up"?
  • Should new projects that use this mode pick node12 or nodenext?
  • There's a big foot-gun with forgetting "type": "module" - should we always recommend .mts?

Next Steps

I see us having 3 options on the table:

  • A mad dash to fix everything - I think this is too hard in the next 2 weeks to pull off.
  • Keep the feature in, but make it inaccessible until we're ready - I think temporarily removing the feature would be impractical. It would probably take more work to remove it than to fix the issues I've already mentioned. So we might as well keep it in.
  • Ship with some "experimental" labeling - I think this makes the most sense, but with some caveats to what I mean by "ship". It would make sense to just ship this as "experimental", but I think we should make this feature only work in nightly releases so that people can continue to easily use it, but not depend on a stable version until it's ready.

I think this issue is a good example for the long needed plugin (hook) system.

The Solution to the first 2 Problems is rollup at present you can use it with plugin typescript to resolve anything correct and then inject it into the typescript program.

i am already researching how i could maintain and release a typescript-rollup version which would be typescript + rollup hooks and plugins.

Conclusion

a Plugin/Hook System is the Solution for the Resolve Problem. The Only one that is flexible and adjustable enough to cover every case.

Conclusion

a Plugin/Hook System is the Solution for the Resolve Problem. The Only one that is flexible and adjustable enough to cover every case.

There is already a hooks system built into package.json... it's bad, but the whole point is to get rid of it as soon as dependencies merge the PR's to fix the issues.

I have the following install hook as a bandaid while upstream applies the fixes to default imports and reachable types/modules.

#!/bin/bash
set -euo pipefail

sed -i '2s/import express/import \* as express/' node_modules/\@feathersjs/express/index.d.ts
sed -i '1s/import http/import \* as http/' node_modules/\@feathersjs/socketio/index.d.ts
sed -i '2s/import io/import \* as io/' node_modules/\@feathersjs/socketio/index.d.ts
sed -i '8s/"source",/"\.\/source\/index\.js",\n"types": "\.\/index\.d\.ts",/' node_modules/chalk/package.json

I'd rather have 4.5 stable sooner, than to wait for yet another workaround.

@rayfoss we can take your example to again show that a plugin/hook system like the one from rollup is badly neeeded.

you mixed linux shell script into your package.json as workaround.

Now we have an ecosystem mismatch between Node.js and bundlers. No bundler is compatible with this new TypeScript mode (and keep in mind, back-end code also occasionally uses a bundler).
Is our guidance to always use this mode for plain Node.js apps, and let tools "catch up"?

Yeah, so the trouble is if TypeScript doesn't encourage the use of node12/nodenext then the package author will be unable to use any packages that use Node ESM. So no matter which choice the author makes some things will be invariably broken.

This is something I've mentioned in the past on some related issue, but has it been considered not to have a distinct node and node12/nodenext mode at all, but rather just use --module node and require the presence of "type": "module" or "type": "commonjs" in package.json? (And regardless of this being present, .mts/.mjs/.cts/.cjs would work always, this would only be required for using .ts/.js in the new mode).

By having both --module nodenext and "type": "module" people are gonna be essentially double-configuring in a lot of cases anyway (most cases?), given people have to add --module nodenext to their tsconfig to enter the mode anyway, I don't see that it would be significantly worse to have them add "type": "module"/"commonjs" to their package.json instead.

Conclusion

a Plugin/Hook System is the Solution for the Resolve Problem. The Only one that is flexible and adjustable enough to cover every case.

This seems like the absolute worst conclusion to ever reach. I'm really sorry for butting in on this issue as not a huge avid typescript users but the one thing I like about typescript is precisely it doesn't require 9001 packages like soo many other builders and bundlers to actually get into a working state.

Typescript is standalone that just-works and doesn't require the end programmer to have to build-their-own-compiler themselves. Add plugins is just gonna be that, making it more and more complicated while adding nothing really. Especially something like this issue that REALLY needs to work out of the box but isn't. And saying to people "oh you're using typescript but typescript is dumb like bundlers, you need to hook plugins to get it working" is something you don't want to say to developers or people.

I really strongly advice against any case of adding plugins to this package. If it doesn't work, we can fix it. If it works, why would you need a plugin just to make configging even more complicated?

Best regards:
TT

P.S.
what is it with developers and wanting plugins in literally everything?

@TheThing in my case it is simply needed because there are many package authors with total diffrent opinions and i do not want to get blocked by them. I also do not want to hardfork everything and so on.

The only Alternativ to a plugin system in typescript is the usage of dev bundels that are typescript compatible.

also my conclusion is driven from the fact that there are tons of other environments not only nodejs

i only vote for resolve hooks and plugins because of all the diffrent environments as also package managers.

Node 14.8 added top-level await, but Node 12 doesn't have it. I think this omission is enough of a wart that starting at Node 12 is the wrong move.

What about removing Node12 and starts from Node 14?

One note here from https://github.com/microsoft/TypeScript/issues/46550#issuecomment-954348769—for users who have declaration emit enabled, error messages like this are probably going to be a common symptom of a dependency that needs to update their export map to include "types" conditions:

error TS2742: The inferred type of 'T' cannot be named without a reference to '../../../node_modules/async-call-rpc/out/full'. This is likely not portable. A type annotation is necessary.

It feels pretty non-obvious that that’s what’s going on so I wanted to make a note of it here. Basically, the declaration emitter wants to print a type like import("async-call-rpc/out/full").T, but it can’t because the package has an export map that doesn’t specify any "types". So the only way it can reach that module is with a relative import through node_modules, which is not allowed to be synthesized by declaration emit. If you look at the package’s package.json, you can see that the actual specifier that should be generated will probably be "async-call-rpc/full", but each of those entrypoints in the export map needs a "types" if it’s going to be resolvable by TypeScript.

☝️ Actually, I was wrong about this particular example. This may be a common symptom for some packages if their typings are stored in a separate folder from the JS that the export maps point to, like RxJs does. However, if the .d.ts files are colocated with the .js/.mjs/.cjs files pointed to by the export map, this should “just work.” This is the case for async-call-rpc, so the fact that the declaration emitter is complaining here is probably indicative of a module specifier resolution bug. (cc @Jack-Works)

Oh. Yes, I found that package actually exports the correct typing and JS file at /full, not /out/full.

There are some rough edges with the new resolution mode. I've been trying out this mode for two weeks.

The main issues I've hit are:

  • unstable import resolution issues in IntelliJ IDEA (looks to be related to #46396 )
  • libraries missing a types declaration (@types/koa-route, raw-body)
  • libraries with an export field but no types condition while the type declarations are in their own directory (rxjs)

Despite all of these issues, I still hope that the new resolution algorithm becomes available on stable TypeScript: the core seems good and bugs/UX can be iterated. The new resolution mode lets me get rid of tons hacks and makes it finally ergonomic to use ESM natively.

  • I no longer have to manually watch out for explicit extensions in relative imports
  • I can use exports with subpath mapping to expose my outputDir directly for deep-imports; while being fully compatible with npm|yarn link and project references, and without having to abuse typeVersions.
  • For complex cases where types are generated directly in the build directory (e.g. using wasm-bindgen), I am now able to use import maps and remove dummy files used just so TypeScript does not complain about missing files.
  • Other various quality of life improvements such as proper handling of import.meta or __dirname.

I was able to use node12 with Node, webpack, mocha, wasm-bindgen, native node modules and Angular 13.

In my personal experience, all of these improvements strongly outweight the current issues: it makes things so much simpler as it realigns TS behavior's with Node's. I hope that the current issues get fixed soon and I hope that the concerns will not delay the new resolution too much.

unstable import resolution issues in IntelliJ IDEA (looks to be related to #46389)

@demurgos did you get the right issue number here? This doesn't sound like it would be related to what you're talking about 🤔

I wanted to link the issue 46396: "nodenext alternates between finding and not finding the imported package"

I've double checked my message: I linked it as #46396 but it looks like GitHub failed to resolve it properly. Editing my message to add a single space to force a refresh fixed the rendering.

Exac commented

What if TypeScript 5 assumes "type": "module" by default?

It would be regrettable if we have to add "type": "module" to our package.json in the far future, when many have migrated.

What if TypeScript 5 assumes "type": "module" by default?

It would be regrettable if we have to add "type": "module" to our package.json in the far future, when many have migrated.

Just to be clear it is Node (not TypeScript) that requires "type": "module" in package.json and that isn't likely to change probably ever. TypeScript just reads the value to understand how Node will run the module, TypeScript doesn't control how the module is run.

at present i think the type fild in the package.json is less relevant as that is only a switch for the .js extension inside NodeJs Typescript at present 4.5+ detects the module type via import and export statments inside the .js files this should not change.

Typescript is not a NodeJS only Product at last i guess that.

ps i still have Javascript Projects without a package.json at all and i use the global installed typescript to typecheck them it works great and it should stay working.

orta commented

For folks who are interested in testing esm-node out:

npm add typescript@4.5.0-dev.20211101--save-dev
yarn add typescript@4.5.0-dev.20211101 --dev
pnpm add typescript@4.5.0-dev.20211101 --dev

This is the closest nightly npm release to the RC, so can act as "4.5 but with ESM enabled" for your projects.

Are the node12 and nodenext module options both being dropped for 4.5? Or just node12?

edit: based on typescript@4.5.1-rc the answer is "both"

tsconfig.json:27:15 - error TS4124: Compiler option 'module' of value 'nodenext' is unstable. Use nightly TypeScript to silence this error. Try updating with 'npm install -D typescript@next'.

27     "module": "nodenext",                                /* Specify what module code is generated. */
                 ~~~~~~~~~~

Correct, neither will be available for TS 4.5.

For what it's worth, I can explain ts-node's short term plans around file extensions in import specifiers.

Today we already allow .js extensions in imports when using our node ESM loader hooks. You can import ./foo.js to execute ./foo.ts. Our ESM resolver hook understands to resolve .js to .ts when the corresponding TS file is found on disk.

In the near-term, I'm working on adding resolver behaviors to CommonJS so that .js extensions will also work for the CommonJS / require() resolver. Once these changes are complete, users will be able to specify the .js extension in all imports, whether they are using the ESM loader hooks, the CommonJS hooks, or both. This should hopefully eliminate a big source of confusion.

I have a pinned issue for this but hesitate to link it here, both to avoid confusing readers and to avoid too many distracting questions. It's a broad issue describing multiple resolver behaviors, file extensions being just one part of it.

  • ts-node, Webpack, and Vite don't like imports with .js extensions, but TypeScript expects them. Not all of these can be configured with a plugin.

FYI Vite 2.7.0-beta.3 has added support for imports with .js extensions when the importer is a ts module.

Will it be possible to use native dynamic import(…)#43329 in TypeScript 4.5 without module: nodenext?

@ExE-Boss the related code is only in the node12 and nodenext features it is not isolated at present but i agree that this could be a extra PR and a extra tsconfig.json setting like preserveDynamicImport. As i see situations where you would want to transpil it and also situations where perserving them is the right choice.

I am mostly excited about support for the "exports" field since it is a key for dual packaging. I hope the "type: module" support to be a secondary concern for now, or at least not something that could slow down "exports".

This was the first Typescript beta I installed because I really wanted "exports". I had some issues, like "exports" not being effective unless a matching "typesVersions" was present. Things could work, but problems were hard to diagnose.

@ExE-Boss it looks like dynamic import isn’t transformed away in module: es2015 and up: playground

FYI Vite 2.7.0-beta.3 has added support for imports with .js extensions when the importer is a ts module.

@sodatea thanks for bringing this to our attention! Do you happen to have a link to an issue/PR on their side where this was discussed? I’d be curious to read about it.

FWIW, as a maintainer of a small corner of the ecosystem, I strongly second this idea:

I would like to see a world where we say "under this mode, .ts files must be covered by a "type": "module"". .cts can do their own CommonJS thing, but they need to be in a .cts file.

While I'm also sympathetic to @weswigham's comments on the design discussion thread, where folks want TS to help bridge the gap, it's not clear to me that you're going to be able to do that in a particularly clean way—and I think it's probably going to be net easier pedagogically and mechanically to tell people something along these lines:

Okay, here's the upgrade flow

  1. Set the new value in tsconfig.json.
  2. Rename all your existing .ts files to .cts.
  3. Make sure all your tests pass!
  4. Convert all .cts files to .ts, with ES module semantics, at will.

That basically matches how I would advise someone to migrate a JS library. It makes ES modules the new default; it preserves all existing behavior; and it also makes the migration status obvious and easy to reason about—and, importantly, finishing the migration is effectively zero-risk: someday you just don't have any CJS files left.

The bigger challenge here is the publication of modules in both modes (supporting exports.require and exports.import, right? I.e. needing separate configs for each mode (presumably extending a base config), with the resulting different semantics, if you choose to publish and support that way. 🤔

@ExE-Boss it looks like dynamic import isn’t transformed away in module: es2015 and up: playground

@andrewbranch Shouldn't this be documented as a breaking change then?

@sodatea thanks for bringing this to our attention! Do you happen to have a link to an issue/PR on their side where this was discussed? I’d be curious to read about it.

@andrewbranch Yeah, the issue was originally discussed at vitejs/vite#3040 and the fix is vitejs/vite#5510

@ExE-Boss it looks like dynamic import isn’t transformed away in module: es2015 and up: playground

@andrewbranch Shouldn't this be documented as a breaking change then?

No, because that’s the existing behaviour.

What I (and #43329) want, is to support preserving dynamic import(…) in module: commonjs (and other non‑ES* module types).

@sodatea thanks for the info and thanks for driving that change in Vite! That’s a big step toward reducing friction for TS users who want to use proper ESM anywhere besides Node ❤️. It looks like that feature went through without much trouble, but feel free to loop me into TS-related issues in the future if you ever need someone from the team to weigh in or take a look at something!

What I (and #43329) want, is to support preserving dynamic import(…) in module: commonjs

Agreed, since CommonJS supports await import(), then TS with module: commonjs should too 👍

Its not just a nice-to-have, its critical for compatibility as seen here: #43329 (comment)

Perhaps that would need to happen in TS 5.x since it would be a breaking change, or maybe somehow enable with target: esnext.

See Playground for more.

The node12 and nodenext module emit modes include that.

@weswigham Yep you're right!

@orta Perhaps this is a bug in the playground after toggling "module" a few times.

See this video which can be reproduced with this link.

Correct, neither will be available for TS 4.5.

FYI the TS 4.5 release notes make no mention of that, maybe it would be helpful to surface that information? I made an attempt to migrate to ESM at work that failed for unrelated reasons, but I did it under the assumption that Node.js /\ ESM support was going to be available in TS 4.5 stable.

Thank you for your hard work!

Thanks for the heads up - I've opened microsoft/TypeScript-Website#2149.

What version of TypeScript will support Node.js then, since this support got scrubbed from the v4.5 release? I can't find it tracked in any of the upcoming iteration plan issues or issue milestones.

@jaydenseric we're committed to shipping this as soon as it's ready; ideally 4.6, but the possibility of a further slip is always present

@skimmilk6877 let me give you some insights as Javascript got created there was "No Module System" so People Invented them. The Current JavaScript/ECMAScript standard has well defined Module System and that is the only source of truth for today. Everything else only shims/polyfills it or transpils to it.

Update to make my point more clear

The Browser Implementation is the only one that matters! having a clear url and protocol without additional resolve step is the only way to go so for local files it is file:// and i would also suggest to only accept unix style urls with that protocol so \ gets /

implementations (engines) can then use the protocol to add additional resolve implementations. for file and http urls there should be no additional resolve step at all in the src code or production code.

It's worth understanding that the implementations by Deno and Node are both compliant with the spec. So are implementations in browsers which resolve things somewhat differently than either! The spec intentionally allows a string identifier to be resolved in different ways by different implementations. This is a necessary design constraint given the many different environments that JavaScript modules exist in, and it's the only reason it's even possible for Node to add support at all! It's also what has allowed tools as different in implementation details as Ember CLI and webpack and TypeScript to use Node's module system under the hood while allowing developers to author in ES modules for the last 5–6 years even though Node itself didn't yet have built-in support for ES modules.

Happy American Thanksgiving, everyone! 🦃🥧🇺🇸

@chriskrycho sure they are both compliant as both are using v8 which is compliant and both transpil to it and there is no resolve in v8

There are two important ways in which your statements (including your clarification) are misguided at minimum:

  1. Transpilation is not what makes them compliant. Any string is a legitimate identifier in the ES Modules spec, and implementors are free to interpret that string as they like.
  2. The browser is not the only environment that matters, especially for the purposes of designing a module system: other JavaScript contexts and runtimes matter, too! Especially Node!

To take a historical example, Ember’s use of ES Modules1 was quite different from either the browser/Deno model or the Node module. (There were good reasons for that, given when Ember adopted ES modules!) The Ember modules happened to be compiled to a bunch of AMD modules to run in the browser—not least because the browsers didn’t yet support the ES module spec, but also because the best authoring format isn’t necessarily the same as the most-optimized runtime format.

So, to reiterate, running in a browser or using transpilation is irrelevant. The Node implementation, including all of its resolution rules, is in and of itself spec compliant. So was Ember’s classic modules design. So are browsers’ and Deno’s use of URLs.

I understand (even if I don’t agree with) the idea that all resolution should just happen via URL. It definitely has its attractions. However, that’s simply not what compliance with the spec requires—and that’s actually a very good thing, given how diverse the JS ecosystem is. And, most importantly for this thread: whatever your or my personal tastes or preferences, TypeScript has to support the ecosystems which actually exist.

Footnotes

  1. currently in the midst of a long transition to use Node module resolution

@chriskrycho good point finaly i found a reason for a new (the next) Programming Language only to drop the existing Ecosystem in favor of a clean one that works. Thanks for your time and inspiration you finally got me on track again the JS/ES thing is dead we need something total new. Something that is a SuperSet of ECMAScript but it should be target environment aware.

GitHubDesktopSetup-x64.msi

It's been about two months now since the last message. Do you know what are the chances we'll get support for Node's ESM in the next stable release?

what is it with developers and wanting plugins in literally everything?

Open-closed principle.

"open for extension, but closed for modification"

https://en.wikipedia.org/wiki/Open%E2%80%93closed_principle

@weswigham you sayed that you want to use exports: { "types": } in package.json but when i add that in current vscode insiders then it yields "types" is not allowed but i am not sure where that comes from. it is not from typescript or is it?

i guess it is not allowed to use something like "types" in exports only pathes are allowed. or path patterns
"./types" would be valid

@DanielRosenwasser not sure if this has been reported, I could not find any issues explicitly mentioning it. But it seems the new "module": "nodenext" resolution is ignoring typesVersions, it is not mentioned in the documentation so curious if this was a deliberate decision.

I came across the issue when using the rxjs types, they rely on typesVersions and types to point to the correct files, and also use exports. Playground Link

I am trying to figure out if I should put a PR in to rxjs to add the types key to the exports map or whether this is a bug with TypeScript's new ESM resolution?

Edit: It seems RXJS is mentioned a few times in this thread. Although that seems to be referencing the fact that it does not add types to exports rather than the reliance on typesVersions.

typesVersions is to main what versioned types conditions are to exports.

@AndrewLeedham One of the things we plan to do before shipping this as stable is auditing the top N most popular npm packages that ship their own types and use package.json exports. We’re aware that RxJS needs an update. If you or someone else makes that PR before we get to it, please ping me on it. Thanks!

@andrewbranch I maintain a package that publishes types and uses package exports. Is there a summary of what we would need to do?

You just need to ensure that typescript@next finds typings for imports of your exports when set to --module nodenext. This will happen automatically if there is a .d.ts file colocated with each .js file referenced by the import map. (IIRC, if these JS files use .mjs or .cts extensions, the declaration files will need to use .d.mts or .d.cts respectively in order to be picked up automatically.) If that’s the case, no changes will be necessary, but you should test and make sure it’s working as expected. If you ship your .d.ts files in a separate folder like RxJS does, you’ll need to add types keys to each leaf entry in exports.

@andrewbranch how is it with exports + types property directly in the package.json not on the exports. they will get completly ignored? i think it would be great to at last fallback to that.

by the way i would love if it would get considered that types for imports would get hornored.
so i could easy overwrite wrong types.

Any exports in a package.json at all will block nodenext resolution from using types and typesVersions fields. That’s by design and will not change. I believe types and typesVersions can still be used for resolutions in nodenext as long as there was no package.json with exports in the way.

We’re aware that RxJS needs an update. If you or someone else makes that PR before we get to it, please ping me on it. Thanks!

I sent a PR for RxJS in October. At the time they were waiting for the feature to be available in stable TS. I assume this is still the case.

It seems a little like too much is trying to be accomplished at once. resolving esm modules seems to work fine right now (even if the imports are extensionless, and the node algorithm is now incomplete), so I’m not sure why nodenext is being added to moduleResolution (At least as a first step).

What we really need, is compiled output that is esm compatible. So add the extra suffix necessary (.js, .json, /index.js, etc.), and don’t worry about resolution and import syntax changes in the MVP. Leave off the new moduleResultions option for now, and keep defaulting it to node when module is set to nodenext. Also, make the nodenext option dumb, and ignore the package type setting. Add an output extension option so people can choose .js, .cjs, or .mjs, explicitly. You can make the default for that option smarter, later (though I’m not sure you should)

No changes needed to existing code (win). None of the side issues that seem to introduced by trying to change the module resolution mechanism (win).

if the issue is “but the TS import paths won’t match the esm spec”, I don’t care, and neither should you. Module transformations and interpretations are here to stay. import->require, classic vs node resolution, type only imports. This ship has sailed. The goal of having explicit paths in esm doesn’t even make sense for typescript, which is going to stay a compiled language. It’s important the output have explicit imports so that runtimes can handle them, not that the developer writes them.

Json imports won’t work in node without the experimental flag, but just call it out in the docs and move on. They’ve already committed to fixing it.

What we really need, is compiled output that is esm compatible

TSC already supports this: you need to add .js extension to every relative imports.
Unfortunately there is no way to enforce this.

What we really need, is compiled output that is esm compatible

TSC already supports this: you need to add .js extension to every relative imports. Unfortunately there is no way to enforce this.

This does not work when importing a ts file (or will it reinterpret js to mean ts if a ts file exists? Doubting it, but I forget if I’ve tried it yet).

This is a large feature because it's the minimum required to support and check node esm as it actually exists today, as it was introduced alongside a suite of complex resolver features to handle esm/cjs interop. "Just give me [whatever one thing that frustrated me in particular]" sound great, but we find people stop trusting their type checker if it fails to check for common problems, and believe it or not, node's esm is a big source of common problems. Plus, once you put together every request like that, you get the whole feature (every part of this feature came from some request!), but with an even more confusing array of toggleable flags, which definitely isn't going to help us.

If what you describe is all you want and you think you can get away without having proper checking, you're free to use module: esnext with moduleResolution: commonjs alongside a babel transformer that patches your import paths (which we'll never do because the process is error-prone unless you're at the app bundle level, and we don't bundle), which should get what you want; and if that's enough for you, great, but it's far from actually accomplishing our job of checking real code as it exists.

“TS modules” are already a thing, with switchable resolution and transform strategies. Happily, until now, they closely resembled node requires. But the ecosystem just got complex enough that that’s no longer true.

Embrace TS modules. Give those bundlers something to target. Give us toggles for the ones that need a specific transform. Toggles absolutely will help.

This dogmatic, “no import path transforms”, is not helping. Please stop. TS operates in two modes today. Transpiling files (where path transforms are easy), and transpiling source (where either the bundler asks typescript how to resolve, or handles the resultion at the bundler). The bundler problem seems solvable.

This does not work when importing a ts file (or will it reinterpret js to mean ts if a ts file exists? Doubting it, but I forget if I’ve tried it yet).

This works: I use this in all my projects since more than 1 year. If I am not wrong the release that introduces this also recommends to go this way. This is the only way to have esm-node-compatible code with TSC.

As an example:

// @filename src/a.ts
...

// @filename src/b.ts
import * as a from "./a.js" // this is automatically resolved to src/a.ts by TSC
...

You have to enable three settings in tsconfig to avoid issues with type-imports, commonjs imports, and wrong dead-code eliminations:

  "compilerOptions": {
    "esModuleInterop": true,
    "importsNotUsedAsValues": "error",
    "preserveValueImports": true,
    ...
  }

This works, I used this in all my projects since more than 1 year. If I am not wrong the release that introduces this also recommand to go this way. This is the only way to have esm-node-compatible code with TSC.

As an example:

// @filename src/a.ts
...

// @filename src/b.ts
import * as a from "./a.js" // this is automatically resolved to src/a.ts by TSC
...

Ah, I didn’t know that! Thanks!

@weswigham look at that, a path transform. Technically, a path resolution, but tell me a transform wouldn’t be better there.

@Shakeskeyboarde By the way I could like the support of .ts imports in order to be compatible with deno...

Plus, we already did the hard work of shipping it (we already did the action items in the OP of this issue, so maybe it's appropriate to close, idk), we're just trying to polish it before it's marked as stable (it is in the codebase and fairly feature-complete) because it's capable of checking so many more real patterns. ❤️

Specifically, for those of you watching at home, we're trying to both make sure its language service features are more bug-free, and we're looking to provide some new type-level syntax that allows reaching across formats and pulling on type information even if your file's default runtime resolution doesn't allow it (which in turn makes declaration emit more reliable). With those in place all we really want is to get more of the people who early-adopted node's exports with TS types to actually list a types condition as well, so we can find types for those exports (if their types aren't adjacent to their js). As embellishment, we're also thinking about how we might be able to help point out common bugs in node export maps, as people can easily misunderstand how they resolve in practice.

@weswigham i do appreciate that. Thank you. I’m just worried because a language I like is making big decisions, and possibly being too clever :).

deno resolution is a separate, even more complex beast, supporting all modern browser features in some scopes (import maps, full url imports), and these new node features in others (it has a node package compat layer), and some custom modifications besides (.ts direct loading, override comments, redirect comments).

I don't think we'd consider it until after we've overhauled a bundler/browser-specific resolver mode first, since it'd need to use such a mode as a base. classic doesn't really map to anything useful nowadays (except maybe not-node_modules-based).

@weswigham i am still for joining the projects rollup and typescript there is a acron to typescript ast and vice versa implementation when the both asts are compatible you can gain a full plugin system for typescript via rollup rollup is basicly a loader not a bundler and it is basicly only a plugin system so a loader with a plugin system.

i am working on that you can contact me by mail and we can schedule something i mentioned that at a few places already. and rollup now got a disk based cache as you can see in the microsoft pwa builder starter example already. Sooner or later it will make sense to create a TypeScript Language server that is able to interpret a tsconfig.js like a rollup.config.js that will then feed everything into the TypeScript Programm (TypeChecker)

TSC already supports this: you need to add .js extension to every relative imports. Unfortunately there is no way to enforce this.

@Conaclos moduleResolution node12/nodenext, the feature being discussed in this issue, enforces this.

By the way I could like the support of .ts imports in order to be compatible with deno...

this issue closes: #33079 is that correct that it is only missing the subpath pattern implementation? while i need to say nightly works with "exports": { ".": { "types": "" } }

This feature should be made available in stable builds, not only nightly and this is why:

We have a project that uses ts-node. ts-node requires a stable peer dependency of typescript.

Thus we cannot update to esm as of yet, since the the node12 module resolution is not available in stable.
Meanwhile a lot of packages that don't use TypeScript switch to esm, which makes it impossible to import those packages in a sync way. Since we are not on esm, there is no toplevel await. Some of our dependencies like node-fetch, have vulnerabilites in the outdated commonjs versions.

Thus we have to create our own verdaccio instance, relabeling a nightly build to a stable version.

Thus we break package compatibility of our Open Source MIT licensed packages for other contributors, that don't have access to our private verdaccio instance...

Please label it as unstable, just don't make it fail in stable builds.

It doesn't "fail" - it just issues a warning diagnostic. Everything actually works in stable same as in nightly.

@weswigham sure? For me it skips the emit when using node12 type module resolution with stable semver.

Do you have noEmitOnError set? Afaik, it's not an unconditionally emit blocking error, though I may be mistaken, and our default behavior is to emit even in the presence of errors.

@weswigham Why would I want to emit on error? Isn’t the while thing about typescript to have type safety?

The process will still print the errors, return a nonzero exit code, and the like - you can just still work with a work-in-progress output file. It's so temporary errors while you're working don't block, eg, live reloading for a preview. You still have errors, they just don't block getting a potentially usable output (that also has the same errors).

@weswigham sounds hacky. Either way we would need to adjust CI to ignore errors then. It works in nightly, but nightly is not supported by ts-node. It would just be nice if typescript wouldn’t actively block esm adoption, when it clearly works already in most cases since a lot of upstream security maintenance gets blocked by it. Just label it as unstable, so people know its unstable as is the —experimental-loader api for esm in nodejs. People are using it, sindresorhus has updated a huge part of his modules, so we kind of need to move with it.

@philkunz maybe you should think about delaying the adoption of node12 nodenext resolvers anyway as i am experimenting with it on large mixed mono repo bases i found out that many packages are incompatible with that resolve mode

they do not got a exports: types: fild or other edgecases where they mix main and exports and then typescript does not support at present subPath patterns

go with a file in your project put that into a extra folder create a tsconfig with node resolve for it and then import the dependencies there see consumption of typescript modules.

when you switch for example to node12 only exports: types will get looked up rest gets ignored. there is no fallback.

i try to writte a fast guide for incremental adoption of the new resolve modes via sub projects and project references.

I'm sorry but can someone please explain why we need nodenext or node12 when we already have the esnext. The documentation seem a little unclear there. So I asked on SO.
https://stackoverflow.com/q/71463698/3370568
If someone could explain it there, that would be very helpful for the community coming new to this topic.

@vajahath Its not about the "module", but mainly about module-resolution with nodenext and node12.

@vajahath it’s a good question; I answered there.

@DanielRosenwasser Could you state what the current status is here? I'm using the node12 resolution mode. It works for me and already helped me identifying esm implementation issues in projects like parcel, lit that are being worked on now.

Can we count on node12 being enabled by default in 4.7.0?

Another request, can we drop Node 12, but start from Node 14? Node 14 starts to support top-level await.

I see the comment #46452 (comment)

One of the things we plan to do before shipping this as stable is auditing the top N most popular npm packages that ship their own types and use package.json exports.

Is adding "types" to "exports" stable enough that package authors can adopt this now in release versions? Or should we be ready but wait? (I have been reading long threads but still unsure!)

Example PR against rxjs: https://github.com/ReactiveX/rxjs/pull/6802/files

And this question prompted by PR for Commander: tj/commander.js#1704

Is adding "types" to "exports" stable enough that package authors can adopt this now in release versions?

100% yes.

Example PR against rxjs

From what I can see, that uses the same types for both the cjs and esm entry points - as far as we know, that means only one of the two exists (cjs if not type:module, esm otherwise). Technically every entry point with a different module kind should probably have its own types entry. (Meaning you should probably use a compound condition)

@weswigham what means the term compund condition? can you give me a code example? as far as i am aware typescript can not even handle subPathPatterns and also not nestedConditions at last in the last nightly it could not do that.

so my question what gets combined? do you mean creating some how a conditional wrapper? in a additional file and reference that via that single entry?

there is as far as i am aware not even a condition Selector see:

what means the term compund condition?

Being very explicit:

"exports": {
  ".": {
    "import": {
      "types": "./types/esm/index.d.mts",
      "default": "./dist/esm/index.mjs"
    },
    "default": {
      "types": "./types/cjs/index.d.ts",
      "default": "./dist/cjs/index.js"
    }
  }
}

It's nesting another conditions object within the one matched.

as far as i am aware typescript can not even handle subPathPatterns

We should, we just don't provide auto-imports for them yet.

there is as far as i am aware not even a condition Selector

Correct, we do not currently allow setting custom conditions like "development" or "production" - just the core "node", "import", "require", "types" (and versioned variants, eg "types@4.8"), and "default" (obviously only one of "require" or "import" is set, depending on usage).

as far as i am aware typescript can not even handle subPathPatterns

We should, we just don't provide auto-imports for them yet.

Is there a separate GH issue for this I can subscribe to? @weswigham

How is syntactic sugar supposed to work? https://nodejs.org/api/packages.html#exports-sugar

{
  "exports": {
    ".": "./main.js"
  }
}

and

{
  "exports": "./main.js"
}

We look for a main.d.ts alongside main.js.

We look for a main.d.ts alongside main.js.

How does compilation work with exports? Do I need to manually generate those d.ts files for each subpath or will tsc handle that?

@jasonwilliams its tsc default behavior to create the files at the same position as the src files if you do not define anything else in your tsconfig you should read the tsconfig docs carefully. everything gets a tandem lookup so if you import or require a file it will strip the file extension and look for the .d.ts file under the same file name that also covers .cjs .d.cts and .mjs .d.mts

@Jack-Works top level await makes no diffrence in the module resolve mode the 12 is simply indicating 12+ it makes no sense to add every version.

Actually it does. TLA is a module system feature (because it mucks with how modules are loaded and isn't downlevelable), which is why it's enabled in module: nodenext, but not module: node12.

@weswigham but TLA does not change anything not even the order in nodejs there are 2 total indipendent trees for the modules there is even a context function to sync the trees. https://nodejs.org/api/module.html#modulesyncbuiltinesmexports.

sure TLA blocks script execution until the other one is inited but thats it when we would abstract all that we could come out with.

maybe we talk out of different views i for my self speaking from the v8 view of things there are 2 diffrent v8::isolate instances running one for the CJS tree and one for the ESM tree while the CJS tree gets shadowed Copyed one time to the ESM tree thats why https://nodejs.org/api/module.html#modulesyncbuiltinesmexports exists. And as TLA will send only a RPC call to the CJS context if needed to require cache that in the instance and return on the internal Queue that it is done then the linking kicks in. If it is not CJS where we do TLA on then nothing changes it stays the same interpreted Stack and will finish in one process-nexttick() loop cycle.

So when we say TLA has impact on resolve then only on Resolve in CJS Context. and there it has less impact because of the linking. Things will fail directly if the CJS code does anything after many process-nextticks there is not much that can be done thats also why https://nodejs.org/api/module.html#modulesyncbuiltinesmexports is documented as there maybe the need to call that from the CJS code after you as the coder Know that you did load fully.

the await call blocks only the instantiation of the current importing script while it is already parsed until the cjs context resolved the module fully instantiated and then link the current state. It does not Block parsing of other tree parts that are not directly related to resolve the current tree that uses await.

The implementation details don't really matter; node 12 esm format files don't support tla. Higher versions of node do in esm files. No version of node supports tla in cjs files. That matrix means it's only enabled for node-ish modes that distinguish between esm and cjs and which are node versions higher than 12, ie, nodenext.

@weswigham thats fully correct but the most people do code for bundlers and all bundlers do support that. I know near no one who directly uses Typescript Compiler Ouput for deployments. So this should be reconsidered some how as it would help to algin the ecosystem out of my view. And the Other Factor that kicks in is NodeJS does not support TLA in CJS context while many other Popular Engines do support that !!!!