Repository navigation
What’s confusing about modules? #51876
Description
Activity
- addedDiscussionIssues which may not have code impactIssues which may not have code impact
on Dec 13, 2022 Is there a difference between
import moduleName from "module-path";andimport { default as moduleName } from "module-path";?Reacted by Andrew Branch, no, David Rogers, YAMAMOTO Yuji, Alistair Smith, Richard Simpson, Gabriel Delépine and Florian Wendelbornandrewbranch commented
on Dec 13, 2022 MemberAuthorMore actionsAny chance you could include a dedicated section or two on why it's not practical to have the compiler transform imports ending in
.tsto ones ending in.js? 🙂I've seen a few of the TypeScript team posting some pretty solid rationale in various GitHub comments over the years, but I can never remember all the reasons, and finding those comments again is quite difficult. It would be amazing to get one clear, objective, well-written explanation/rationale that could be easily linked to by anyone whenever this comes up.
Reacted by Andrew Branch, Ryan Cavanaugh, TZ | 天猪, flexiworld, Axel Rauschmayer, squaresmile, YAMAMOTO Yuji, Nathan, Richard Simpson, David Burian and 6 moreOne thing that tripped me up was how
.mtsand.ctsfiles interact with package.jsonexportsfields. Especially in the case of dynamicimport()(which always uses theimportcondition, even in .cts files).Reacted by Andrew Branch, TZ | 天猪 and zanminkianAndarist commented
on Dec 14, 2022 ContributorMore actionsWhat are the exact requirements for publishable packages? And how do they relate to:
package.json#type,package.json#exports,moduleResolutionvalues? When do extensions in.d.tsmatter? And what they should be?Reacted by Andrew Branch, YAMAMOTO Yuji, Richard Simpson and zanminkianAn import path cannot end with a '.mts' extension. Consider importing './XXX.mjs' instead.
Why does this error message even exist? It seems insane to direct tsc to import a .mjs when we have a .mts with types and everything. What am I missing here? Is there a way to direct tsc to the real type info? That suggestion certainly doesn't sound like something I want to do.
Reacted by Anders RingqvistI'm working on a demo about many issues with the current
module,moduleResolution,allowSyntheticDefaultImports, andesModuleInteropcombinations.It's still a work in progress. I can probably finish it by this weekend or next week.
Feel free to check it out or cooperate about this topic.
https://github.com/cyberuni/typescript-module-resolutions-demo
My team is building a non-fancy web-app (not a library/framework) in a monorepo setup. Are there any different configs needed for app vs. library authors?
I would love to see some kind of cli check -> "Is my setup ok?" like
brew doctoror https://github.com/bluwy/publintReacted by Andrew Branch, Richard Simpson and Florian WendelbornCross linking the great post made by Ryan: #49083 (comment)
There are still many things worth to discuss and figure out, for example:
- How to actually consume packages in today's CJS/ESM world
- How to support both as a library? Maintaining multiple versions? Duplicate code?
- How to make the life of TypeScript library authors easier today. What can be done by TypeScript, community tools, library authors?
- What's the TypeScript and JavaScript community direction
But in general, that is a good read and should check it out.
Reacted by Andrew Branch and Richard SimpsonSeems like this isn't really the place to argue over ESM-only vs CJS compat...
Reacted by Anthony Frehner, Homa Wong, Andrew Branch, Ryan Cavanaugh and Will Slattum32 remaining items
Supported since version 4.7? It's only the auto-completion that will be added in 5.4, right?
Yes, the module resolution part of things was released in 4.7 (release note here). 5.4 just offers new auto-completions in this area
Reacted by Andrzej WódkiewiczAn example test case for this can be found here.
The key word in the underlying PR is "roughly". Since
pathsand#importsuse different ways to map several files to a path, it will only "just work" if the mapping is one-to-one, which can easily not be the case in situations like a bundled NodeJS package (and I bet the errors will be as confusing as always). So the point still stands,#importsfield is still a footgun for typescript codebases, with ugly workarounds.Andrzej Wódkiewicz (@akwodkiewicz)
I assumed they are relying heavily on some bundler.
While technically true, it's very unlikely you'll end up in a monorepo situation without all js code lathed in typescript (which might or might not be a bundler) and without a build step.
Reacted by Andrew BranchSo the point still stands, #imports field is still a footgun for typescript codebases, with ugly workarounds.
It seems we happen to work on projects with entirely different DX -- hence the disagreement.
We usually seem to be stuck between choosing solutions that either require lots of configuration or those that are super magical and in case of a non-standard issue require reading their source code to understand what they are doing.
I'm searching for a minimal, but a non-magical approach, where we are as compliant with various tools as we can get. And I'd like the TS docs to help other people get there as well.
And I believe that #imports are a "native" alternative to the path aliases, at least for Node, because they just provide the "type layer" on the stuff that is already supported by the runtime. So no bundler magic, no unnecessary configuration -- it's the sweet spot.
Let me go back to the original issue, which is about the docs themselves. The aforementioned path aliases, that 5 years ago were not explained well enough and made people (ab)use them as import syntax-sugar, today they have a single essential paragraph explaining they should not be used to reference other packages in a monorepo. It's great that this particular piece of information is now in the docs, and it's great that Yarn/pnpm workspaces are mentioned as the alternative.
What I don't get though, is that the self-referencing path aliases seem to be endorsed in the next subsection of the same document. The docs could be more specific that this is probably a good idea only if you work with an additional build step other than just tsc. And here we could be mentioning #imports as the alternative, in the same way we are mentioning Yarn workspaces as the alternative for aliases to internal libs.
Moreover, I know that the information about paths not being emitted is 2 sections above, but since the documentation is not separated into "vanilla tsc + Node/browser" and "things that make sense if your bundler allows it", reading the "wildcard pattern" section alone can still mislead developers into using the path aliases without understanding their consequences.
EDIT: what's also confusing is the part where baseUrl stopped being necessary for the path aliases to work. As a user of path aliases I obviously thought it's a good idea to reduce unnecessary "baseUrl: '.'" entry in my config. But today, knowing about the original idea behind the feature, I'm wondering if it was a good idea that we simplified the usage of the feature that at the same time we try to warn people about (did it make sense to drop baseUrl in the context of usage with RequireJS? I assume not)
Reacted by Homa WongReacted by Artur Kozakandrewbranch commented
on Feb 2, 2024 MemberAuthorMore actionsI don’t know how anything in that document can be read as an endorsement. It’s a reference page that has to spell out how features work. I prefixed the section with a big disclaimer about what not to do, but eventually I had to put some examples on the page.
I'm wondering if it was a good idea that we simplified the usage of the feature that at the same time we try to warn people about (did it make sense to drop baseUrl in the context of usage with RequireJS? I assume not)
When I removed the need for
baseUrlto usepathsin TypeScript 4.1, we were not yet as anti-pathsas we have grown to be. However,baseUrlhas absolutely no use in bundler code or Node.js, whilepathsremains useful in some cases. I have literally never seen a project usebaseUrlfor any reason besides to usepaths, and yet it creates a broken way to refer to every file that people had to be careful to avoid. They fundamentally never needed to be coupled, so yes, I still think it was absolutely the right choice to untangle them. I will advocate for deprecatingbaseUrlin 6.0, whilepathswill likely live forever, however many caveats we attach to it.Reacted by Andrzej Wódkiewicz and Artur KozakYou are right, I went too far with endorsement. What I was trying to say is that I thought that the usage of paths for imports convenience was not meant to be officially recognized while the docs show it. But reading your reply I understand that my assumptions were wrong and the de facto usage of paths was embraced by the maintaining team (hence the decoupling of baseUrl from paths).
Thank you, Andrew Branch (@andrewbranch), for sharing your thoughts on this matter.
I have literally never seen a project use baseUrl for any reason besides to use paths, and yet it creates a broken way to refer to every file that people had to be careful to avoid.
Do you mean some specific issue that can happen when using baseUrl? What is this way you're mentioning if I may ask?
andrewbranch commented
on Feb 2, 2024 MemberAuthorMore actions{ "compilerOptions": { "baseUrl": "./src" } }This means that a file
./src/foo.tscan be imported like this:import foo from "foo";
from any file in the program, no matter its location. All “bare specifiers” like this are looked up in the
baseUrldirectory before tryingnode_modulesor whatever themoduleResolutionsetting implies should happen.Basically, you’re saying that an AMD loader is going to make an HTTP request with URL fragment
foo, and there’s an HTTP server serving the built output of thesrcdirectory. But I’m not sure how fully that was even thought through when it was a plausible way you’d ship JS to the browser, because the resolved URL of the request is going to be dependent on the URL of the current page the script is executing from. For example, I just opened up the console and didfetch("foo")and it made a request tohttps://github.com/microsoft/TypeScript/issues/foo, nothttps://github.com/foo. If I wanted my imports to work as HTTP requests from any page, it seems like I ought to use a leading/, butbaseUrldoesn’t allow that. Maybe the AMD loader accounts for this somehow, in a way that modern ESM imports from the browser wouldn’t. Whatever the reason, it’s completely irrelevant to Node.js and bundlers, and is very poorly applicable to browser-based ESM too.Reacted by Andrzej WódkiewiczMateusz Burzyński (@Andarist) , coming back to the "imports" thing -- I can't make them work in my project.
Cannot find module '#lib/something' or its corresponding type declarations. ts(2307)But I also don't understand how TS is supposed to work here. Could I ask for your help here? Please, take a look at the following example.
Having these compiler options in
tsconfig.json:"module": "NodeNext", "moduleResolution": "NodeNext", "rootDir": ".", "outDir": "dist",
and these entries in
package.json:"type": "commonjs", "imports": { "#lib/*": { "default": "./dist/src/*.js" } }
I'm trying to import anything from the top-level of the package with"
import { something } from '#lib/something'
but it does not work. So here are the questions that could help me understand the resolution algorithm better (and at the same time fix my project configuration 😅):
-
Where should tsc look for the types?
"exports" define how the package should work for the users of the package, that's why it's obvious paths to
.jsfiles inexportsare substituted to.d.tsfiles in the same directory by default. But "imports" define how the package should work for... maintainers of the package? Both users and maintainers? We cannot tell tsc to resolve the imports using thed.tsfiles, because they first have to be generated by tsc, and it won't be able to generate them if it cannot resolve the imports, because .d.ts files do not exist yet 😵💫So I suppose that tsc should look at the source code (
.ts) files when resolving the imports. In my case./src/*.ts -
In my case, where does tsc look for the types for `"default": "./dist/src/*.js"?
Does it know it should be
./src/*.ts? Or does it look in./dist/src/*.ts? Or maybe it looks at some other location?Is it possible to print this information somehow on my computer to learn where it tries to resolve
#lib/something? -
Should I add a
typesconditional import?If the answer to 2. suggests that tsc looks at a different place than I want it to look, do I have to tell it to look elsewhere using conditionals (for example
"types": "./src/*.ts")? -
Is my imports entry wrong?
Am I using the wildcard properly? Should the path end with
.js? -
Does the import in source code refer to some specific file that I don't have in my project?
Maybe
import {something} from '#lib/something'is resolved to some specific file like./src/something.tswhere I expect it to resolve to./src/something/index.ts -
Are there any compiler options (or other tsconfig options) that have the effect on the feature?
Maybe I have some more options set with some particular values that break the resolution? I did not paste all of my tsconfig options here on purpose.
EDIT: 7 [META]: Is there a specific place where I could read all about this without using your help and time and/or referring to comments under GH issues 😅
-
Andrzej Wódkiewicz (@akwodkiewicz) i'll try to respond to questions sometime later - but when it comes to the given example, could you wrap it up in a repository that I could clone? that would save me some time
Reacted by Andrzej WódkiewiczBtw. you can use
--traceResolutionto see how TS tries to resolve all of this. You might be able to spot the problem in the output.Reacted by Andrew BranchMateusz Burzyński (@Andarist), I was preparing the reproduction repository and was able to reproduce the issue, but I started messing with the
tsconfig.json+package.json+importstatement and I finally made the imports work. I'll post a bigger comment in a moment (currently writing it), just letting you know that you don't have to go all the way with the explanation.It all boiled down to me not understanding how Node works.
Pointing to folders will not work for subpath imports. Your imports have to point to particular files. Or more exactly, the sum of the import mapping and the thing in your import statement has to result in a particular file. I'll explain in the examples below in a while.
"Folders as modules" seem not to work for subpath imports. It's not mentioned explicitly that it won't work, there's just a following line in the docs:
Using package subpath exports or subpath imports can provide the same containment organization benefits as folders as modules, and work for both require and import.
which suggests that "subpath imports" replace "folders as modules". And the "folders as modules" feature itself is marked as legacy anyway.
So if you have a tree like this:
. |- dist | |- foo | | |- index.js | | | |- bar | | |- index.js | | | |- baz | |- index.js | |- package.jsonand you want to import from
fooin sibling foldersbarorbaz, then the subpath import#lib/foohas to point exactly to your./dist/foo/index.jsfile, like so:"imports" { "#lib/foo": "./dist/foo/index.js" }
If you don't want to specify a separate subpath for each directory, you can try using wildcards, like this:
"imports" { "#lib/*": "./dist/*/index.js" }
The above will work even for nested directories, because the asterisk is not a proper glob pattern, it's just string substitution.
Now, if you make the mistake of specifying the path mapping like this:
"imports" { "#lib/*": "./dist/*" }
assuming that Node will resolve the folder as a module, then it won't work for
'#lib/foo'. You'll need to import from'#lib/foo/index.js'.Or if you decide to use:
"imports" { "#lib/*": "./dist/*.js" }
then the correct import statement will be even weirder:
'#lib/foo/index'.———-
When I wrote the import statement including the word “index” in my project, the type acquisition worked out of the box, even without adding any conditional “types” entries to “imports”.
Now the answers to my questions are not that important, I guess, but it still would be useful to know how TS manages to find type information for the subpath imports. I believe it relies on the information from
rootDirintsconfig.json. But if I knew how it works exactly, then I'd know if it's even necessary for me to write a nested"types"entry, and what are the cases that require it.Reacted by Andrew Branch, Ryan Cavanaugh and Vinit Kumbharkarandrewbranch commented
on Feb 8, 2024 MemberAuthorMore actionsYeah, that is a tricky subtlety. Note that it applies to
"exports"too.{ "name": "foo", "exports": { "./*": "./dist/*" } }// main.mjs import "foo/bar.js";
// main.cjs require("foo/bar")
Given a file
node_modules/foo/dist/bar.js, you might expect each of these to work—the ESM import has to supply the file extension while the CJS require is allowed to drop it as usual. But the normal rules don’t apply once you go through animportsorexportsmapping. Like you said, the result of the wildcard substitution has to be a full filename (relative to the package.json). Node.js really wanted theimports/exportsalgorithm to produce exactly zero or one file lookup location per request for performance reasons (file system access is expensive, to say nothing of HTTP requests in a hypothetical world where these resolutions might take place across a network).This is also one way the
imports/exportsresolution algorithm diverges from the tsconfigpathsalgorithm. Withpaths, extension searching and directory modules still work on the result of the wildcard substitution, if it would happen for an equivalent request that didn’t map throughpaths.Reacted by Andrzej Wódkiewicz and GabenGarBut the normal rules don’t apply once you go through an imports or exports mapping
I have just said jokingly to a colleague that “Node forgot to backport the folders as modules feature to imports”.
I understand the decision, and seeing the feature marked as legacy helped me tie the pieces together. That was the similarity of exports and imports I failed to recognise.
I thought the similarity is the support of the conditional “types” entry. I got too fixated on trying to point the type sources to TypeScript with the conditional entry, that I just did not realize that I’m writing improper code, resulting in Node runtime errors after transpilation.
This is also one way the imports/exports resolution algorithm diverges from the tsconfig paths algorithm. With paths, extension searching and directory modules still work on the result of the wildcard substitution, if it would happen for an equivalent request that didn’t map through paths.
And the reason it will still “work in runtime” (when translated with the help of bundlers or tools like tsconfig-paths) is that these alias paths end up being relative paths. And “folders as modules” is a feature working exactly just with the relative paths.
Reacted by Kuba Jastrzębski
After #51669 is merged, I plan to write documentation for it, and try to update/rewrite a bunch of our existing module-related documentation. While there are a lot of good examples in this issue tracker of specific questions and misconceptions about modules and TypeScript’s module-related options, I thought I’d ask explicitly what questions you have and what aspects of the module landscape or our configuration specifically are the most confusing.