Skip to content

What’s confusing about modules? #51876

Description

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.

Activity

  1. GabenGar commented on Dec 13, 2022

    @GabenGar

    Is there a difference between import moduleName from "module-path"; and import { default as moduleName } from "module-path";?

  2. andrewbranch commented on Dec 13, 2022

    @andrewbranch
    MemberAuthor

    (I don’t plan to answer all or even most questions here, but if the answer is quick and contains any context that may not make it into the docs, I might.)

    GabenGar no, except there accidentally was a difference in type resolution for a while, but it’s fixed now: #49567 fixed by #49814

  3. treybrisbane commented on Dec 13, 2022

    @treybrisbane

    Any chance you could include a dedicated section or two on why it's not practical to have the compiler transform imports ending in .ts to 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.

  4. IanVS commented on Dec 13, 2022

    @IanVS

    One thing that tripped me up was how .mts and .cts files interact with package.json exports fields. Especially in the case of dynamic import() (which always uses the import condition, even in .cts files).

  5. Andarist commented on Dec 14, 2022

    @Andarist
    Contributor

    What are the exact requirements for publishable packages? And how do they relate to: package.json#type, package.json#exports, moduleResolution values? When do extensions in .d.ts matter? And what they should be?

  6. khmseu commented on Dec 24, 2022

    @khmseu

    An 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.

  7. unional commented on Jan 7, 2023

    @unional
    Contributor

    I'm working on a demo about many issues with the current module, moduleResolution, allowSyntheticDefaultImports, and esModuleInterop combinations.

    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

  8. bobaaaaa commented on Feb 18, 2023

    @bobaaaaa

    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 doctor or https://github.com/bluwy/publint

  9. unional commented on Feb 18, 2023

    @unional
    Contributor

    Cross 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.

  10. GabenGar commented on Feb 20, 2023

    @GabenGar
  11. unional commented on Feb 20, 2023

    @unional
  12. GabenGar commented on Feb 21, 2023

    @GabenGar
  13. IanVS commented on Feb 21, 2023

    @IanVS

    Seems like this isn't really the place to argue over ESM-only vs CJS compat...

  14. GabenGar commented on Feb 22, 2023

    @GabenGar
  15. 32 remaining items

  16. akwodkiewicz commented on Feb 2, 2024

    @akwodkiewicz

    Supported since version 4.7? It's only the auto-completion that will be added in 5.4, right?

  17. Andarist commented on Feb 2, 2024

    @Andarist
    Contributor

    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

  18. GabenGar commented on Feb 2, 2024

    @GabenGar

    Mateusz Burzyński (@Andarist)

    An example test case for this can be found here.

    The key word in the underlying PR is "roughly". Since paths and #imports use 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, #imports field 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.

  19. akwodkiewicz commented on Feb 2, 2024

    @akwodkiewicz

    So 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)

  20. andrewbranch commented on Feb 2, 2024

    @andrewbranch
    MemberAuthor

    I 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 baseUrl to use paths in TypeScript 4.1, we were not yet as anti-paths as we have grown to be. However, baseUrl has absolutely no use in bundler code or Node.js, while paths remains useful in some cases. 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. 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 deprecating baseUrl in 6.0, while paths will likely live forever, however many caveats we attach to it.

  21. akwodkiewicz commented on Feb 2, 2024

    @akwodkiewicz

    You 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?

  22. andrewbranch commented on Feb 2, 2024

    @andrewbranch
    MemberAuthor
    {
      "compilerOptions": {
        "baseUrl": "./src"
      }
    }

    This means that a file ./src/foo.ts can 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 baseUrl directory before trying node_modules or whatever the moduleResolution setting 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 the src directory. 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 did fetch("foo") and it made a request to https://github.com/microsoft/TypeScript/issues/foo, not https://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 /, but baseUrl doesn’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.

  23. akwodkiewicz commented on Feb 8, 2024

    @akwodkiewicz

    Mateusz 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 😅):

    1. 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 .js files in exports are substituted to .d.ts files 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 the d.ts files, 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

    2. 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?

    3. Should I add a types conditional 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")?

    4. Is my imports entry wrong?

      Am I using the wildcard properly? Should the path end with .js?

    5. 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.ts where I expect it to resolve to ./src/something/index.ts

    6. 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 😅

  24. Andarist commented on Feb 8, 2024

    @Andarist
    Contributor

    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

  25. Andarist commented on Feb 8, 2024

    @Andarist
    Contributor

    Btw. you can use --traceResolution to see how TS tries to resolve all of this. You might be able to spot the problem in the output.

  26. akwodkiewicz commented on Feb 8, 2024

    @akwodkiewicz

    Mateusz 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 + import statement 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.

  27. akwodkiewicz commented on Feb 8, 2024

    @akwodkiewicz

    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.json
    

    and you want to import from foo in sibling folders bar or baz, then the subpath import #lib/foo has to point exactly to your ./dist/foo/index.js file, 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 rootDir in tsconfig.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.

  28. andrewbranch commented on Feb 8, 2024

    @andrewbranch
    MemberAuthor

    Yeah, 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 an imports or exports mapping. Like you said, the result of the wildcard substitution has to be a full filename (relative to the package.json). Node.js really wanted the imports/exports algorithm 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/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.

  29. akwodkiewicz commented on Feb 8, 2024

    @akwodkiewicz

    But 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    DiscussionIssues which may not have code impact

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions