How to make IntelliSense aware of exports in package.json?
Asked Answered
H

2

13

I got a package.json where I export different scripts using the exports field.

"exports": {
  ".": {
    "default": "./dist/main.es.js",
    "require": "./dist/main.cjs.js",
    "types": "./dist/main.d.ts"
  },
  "./utils": {
    "default": "./dist/utils.es.js",
    "require": "./dist/utils.cjs.js",
    "types": "./dist/utils.d.ts"
  },
  "./segments/*": {
    "default": "./dist/webvtt/segments/*.es.js",
    "require": "./dist/webvtt/segments/*.cjs.js",
    "types": "./dist/webvtt/segments/*.d.js"
  }
}

The file structure is as follow

dist
├── main.cjs.js
├── main.d.ts
├── main.es.js
├── utils.cjs.js
├── utils.d.ts
├── utils.es.js
├── vite.svg
├── vtt.cjs.js
├── vtt.d.ts
├── vtt.es.js
└── webvtt
  ├── segments
     ├── Comment.cjs.js
     ├── Comment.d.ts
     ├── Comment.es.js
     ├── Cue.cjs.js
     ├── Cue.d.ts
     ├── Cue.es.js
     ├── Header.cjs.js
     ├── Header.d.ts
     ├── Header.es.js
     ├── Segment.cjs.js
     ├── Segment.d.ts
     ├── Segment.es.js
     ├── Style.cjs.js
     ├── Style.d.ts
     └── Style.es.js

In VSCode, it now shows the utils and segments as exported paths

enter image description here

However, when importing scripts from segments it doesn't show which scripts I can import from that path.

enter image description here

But if I continue, and import any of the scripts from the segments folder, it works fine.

enter image description here

How can I make the IntelliSense show me the scripts I can import from the segments path?


The repo containing the source can be found here

https://github.com/codeit-ninja/js-vtt

Hippodrome answered 12/8, 2022 at 14:26 Comment(11)
Its hard to tell if your project is configured correctly, can you post your entire package.json, and any other package.json configurations your using (w/o the dependencies and repo links of course). Also your tsconfig.json files. The reason I ask is because your correctly using the export field, and it is likely to do with the way your configuring node to resolve the two different module types, or the way you have engineered the file-system. a copy of a file tree 2 or 3 levels deep would help to (again w/o your dependencies included in the tree).Carrot
An easier way would be a link to a public repo, if it is published as such.Carrot
I added the repo, it contains the code as shown above. However, I tested it as NPM package before actualy publishing it. I am still working on it, so the package hasn't been updated with a proper readme.Hippodrome
Took me almost 4 hours to answer this. I tried to fully explain how Module Resolution works, because that is what provides the information to intellisense about what is available and what is not. If your not getting the selection in the menu when you attempt to import somthing, its because its not being resolved correctly. And I can look at your tsconfig and see that your configuration is not harmonious (I explain what I mean by that in the answer).Carrot
Also, check that your javascript files have import statements in the ones you expect to be esm, and require statements in the commonjs. In other words make sure everything is as it seems.Carrot
Its very likely that you will have some follow up questions, which if you do, I will be glad to answer.Carrot
It dawned on me that you I was wrong about being critical of the way your exporting types, 2BH I am not at all familiar with VITE. One thing I want to ask is if your going to publish this as a package (whether private or public), or maybe a better way of asking my question would be, "Is anyone else going to consume your package as a dependency?"Carrot
Damn, you toke your time haha. Yes this package will be published as a public package on NPM. Thats why in the question you see that I was testing the package as an NPM package to verify everything is working correctly.Hippodrome
Oh, okay, ya I read that, it was late and I got tired. I probably got a little carried away, but I've spent the last couple months researching this very topic. This is a demo project I use now for creating dual modular CJS/ESM packages that I publish. It is also referenced heavily at the end of my answer, and is where I C&P snippets from. github.com/JAYD3V/color-formatCarrot
This question could really be answered simply, which would be, "your intellisense isn't working, because your project is correctly configured to resolve, or to be resolved as, esm & commonjs." Of course that answer isn't very helpful so I wrote a small book to make sure I was thorough.Carrot
Yes, it is very thorough. I need some time to read and understand it all and also have to test my package to see if I can resolve it with the info you gave me. So ill need some time :)Hippodrome
C
29

Your project's configuration is not in harmony with itself

Meaning, you have set certain fields in both your package.json & tsconfig.json files that conflict with other settings within those same files. The most notable was the way you have configured your project to resolve modules, which I'll explain.

Like many other package maintainers, you have opted to add modular support for both "ECMAScript Modules" (aka ESM) as well as "Common-JS Modules" (aka CJS). Also like many package maintainers, your failing to configure your package such that both modules exist harmoniously with one another, to be more specific: The way that you have configured module resolution for TS (in other words, the tsconfig.json field "moduleResolution") is different from how Node.js is currently being configured to resolve modules in your package.json configuration.

You also have not explicitly defined a build for both ESM & CJS, furthermore; you have no entry point defined for your module when it is being consumed via an import, nor for when it is consumed via a CJS require() method.

NOTE: I wouldn't bombard you if it were not necessary for you to fix your package. I have spent 2 - 3 hours writing this in hopes to share what took me months to learn.

Module Loaders

So its important to just clarify what Module loaders are, and specifically what "Module Loaders are to Node.js". Module loaders are the syntax used by developers to indicate the want a certain resource to be consumed. Node.js understands two different loaders, both of which you are probably familiar with.

NOTE: I am using Node's file-system fs library, simply for example purposes only. I chose fs simply because it is familiar to many.

#1 — The ECMAScript Module Loader

  • import * as fs from 'node:fs';

Its important to note that this loader is not only syntactically different, but it is also mechanically different as it is asynchronous.


#2 — And the CommonJS module loader:

  • const fs = require('node:fs');

    You probably were able to infer that the require loader is synchronous.

That asynchronous, and synchronous difference between the two loaders makes the two modules incompatible in most situations (any exceptions are beyond the topics that we are covering).


Your package.json file

Now, with the above said, lets look at your package.json file.Inside of your package.json file you have set the "type" field as shown bellow.

    { 
      "type": "module"
    }

Setting "type" as "module" in and of itself is not a bad thing, however, the way you set this field will affect the way that Node.js handles your emitted JavaScript project (and really, the JS files are the actual mechanics right? TS is more or less just a statically typed blue print in a sense). Looking at it that way, you can easily see that it is extremely important to be aware of how Node.js is working. It is important that you configure TypeScript to work in harmony with node, or else you will experience undesired results, like "your intellisense not working as expected".



Its important that these 4 pertinent points are understood

  1. Setting the "type" field to "module" causes Node to treat .js files as ESM JavaScript
  2. When setting "type" to "commonjs", or omitting it altogether, will configure node to treat .js files as CommonJS JavaScript.
  3. Then of course .mjs files are always loaded as ESM, despite the value of the nearest package.json file's "type" field.
  4. And .cjs files are always loaded as CommonJS, despite the value of the nearest package.json file's "type" field.

Configuring TypeScript's Module Resolution

There is more than one mistake in your configuration, however, the most notable, and easiest to fix, is the value you set for the tsconfig.json field "moduleResolution". Your "moduleResolution" field, is currently configured as shown bellow. tsconfig.json to ["node"][1], as can be seen below...

    "compilerOptions": { 
      "moduleResolution": "node" 
    }

It should be noted that setting "moduleResolution" changes (in part) the resolution strategy used by TypeScript. In your case, you set it to "node", which tells TS to mimic Node's CommonJS module resolution algorithm`.

Here is where we get to the good stuff

So, in other words, if you have your package.json's "type" field set to "module", your telling node.js to resolve ".js" files as ECMAScript modules, which as stated above, is an Asynchronous way of resolving modules.

And if you have set "moduleResolution" in your tsconfig.json file to "node", you are telling TypeScript to mimic the algorithm used by "commonjs" modules, which is a synchronous resolution strategy that follows a completely different spec than ESM.

Do you see how your project is not configured to resolve modules harmoniously?

I don't know all the details on how Intellisense works for TypeScript in VS Code, but I do know it involves a language server, which is powered by TSC itself, and the way that TSC is configured to resolve modules is going to have an affect on the way Intellisense behaves when importing & exporting files. It won't break IntelliSense, but if a resource cannot be resolved in an import statement, IntelliSense is going to show you a list without the "whatever it is" your looking for.

Also, I don't know why your exporting your types, that is also going to affect things. You should export your types by setting the "types" field in your package.json file.

according to TypeScript documentation:

TypeScript will use a field in package.json named types to mirror the purpose of "main" - the compiler will use it to find the “main” definition file to consult.

NOTE: ""types" has an alias, which is "typings""

You want to set the two like this:

// "tsconfig.json"
{
  "compilerOptions": {
    "declaration": true,
    "declarationDir": "./types"
  }
}
    // "package.json"
    {
      "types": "./types"
    }

Configuring your Exports a bit Differently Maybe?

I do the follwoing

Build Directories

Module Type FilePaths
CJS "builds/CommonJS"
ESM "builds/ECMAScript"

Exported Entry Points

Module Type FilePaths
CJS "./builds/CommonJS/main.cjs"
ESM "./builds/ECMAScript/main.mjs"

Source Directory Tree

    .
    ├── lib
    │   └── sum-lib.ts
    |
    ├── main.cts
    └── main.mts

Emits to the following build tree

    .
    ├── CommonJS
    │   ├── lib
    │   ├── main.cjs
    │   └── package.json    <--------  Notice the package.json?
    |
    └── ECMAScript
        ├── lib
        ├── main.mjs
        └── package.json    <--------  Notice the package.json?

The CommonJS & ECMAScript directories both contain a package.json, that doesn't include the projects base-dir, when including the base package.json file, the project has 3 package.json files all together. You can get by with only 2, but I like 3, it makes the configuration more robust, and harder to break when changes are introduced to the projects file-structure.

The package.json files in the CommonJS & ECMAScript directories are simple — very very simple — they contain only what is absolutely needed.

And just FYI, I am showing you the package.json files because it looks like your not defining a commonjs module anywhere, which can only be done using a package.json file, and each module type needs to be defined by its own package.json file. I see that you set a CJS entry, and have CJS files, but no explicit configuration pointing to a CJS module which is also enough to BREAK YOUR INTELLISENSE (i used bold because it answers the direct question).

The extra package.json files in the build I showed you using file trees looks like this.

    // ECMAScript/package.json
    {
      "type": "module"
    }
    // CommonJS/package.json
    {
      "type": "commonjs"
    }

Project's base package.json file

    /* 
    > "package.json"
    > I left out dependencies, repo url, and other things, and left the 
    important settings */

    {
      "name": "rgb-interface",
      "author": "Andrew Chambers <[email protected]>",
      "version": "0.0.3",
      "type": "module",
      "types": "types",
      "exports": {
        "import": "./builds/ECMAScript/main.mjs", // ESM Entry
        "require": "./builds/CommonJS/main.cjs" // CJS Entry
      },
    }
}
// I didn't leave out "main", I just don't define it, as exports defines my entry points"

There is only one ".mts" file & one ".cts" file, the rest are simply ".ts". So long as I define the project's modules correctly, so that the same algorithm is being used by typescript & node.js, everything works harmoniously. I can import from the same files to main.cts & main.mts without maintaining seperate cts & mts versions of the same file. One source, and 2 builds.

TypeScript Configuration

You have to configure typescript to build a CJS build, and to build an ESM build. There are several different concepts that all achieve the samething, but they each look & work very differently from each other. The most simple way in my opinion takes advantage of newer TS 3.X features that were introduced for the purpose of emitting multiple builds, henceforth, the $ tsc --build command + flag.

In a TSConfig you just want to reference the two different builds.

{
    "files": [],
    "references": [
    { "path": "tsconfig.esm.json" },
    { "path": "tsconfig.cjs.json" }],

    "compilerOptions": {
        "incremental": true,
        "tsBuildInfoFile": ".Cache/tsc.buildinfo.json",
        // "listEmittedFiles": true,
        "forceConsistentCasingInFileNames": true,
        "noEmit": true,
        "listEmittedFiles": true
    }
}

then use two other tsconfig files for the builds.

Like this:

    // "tsconfig.esm.json"
    {
      "files": [
        "src/main.mts",
        "src/test/esm-color-format.test.mts",
        "src/lib/ansi-static.ts",
        "src/lib/color-log.ts",
        "src/lib/ansi.ts"
      ],

      "exclude": ["node_modules", "**/*.cts"],
  
      "compilerOptions": {
        // ECMAS Module + ES2021 + Node-16LTS
        "target": "ES2021",
        "module": "Node16",
        "moduleResolution": "Node16",
        "esModuleInterop": false,
        // STRUCTURE
        "outDir": "builds/ECMAScript",
        "declarationDir": "types",
        "rootDir": "src",
        "sourceRoot": "src",
        "composite": true, // <-- must be on
        // EMISSIONS
        "declaration": true,
        "declarationMap": true,  // maps add extra intellisense features
        "sourceMap": true, // both "*.d.ts.map" and "*.js.map" files
        "inlineSources": true,
        "noEmitOnError": true,
        "noEmit": false, // Can be used when you want to emit only one build
    }




    // tsconfig.cjs.json
    {
      "files": [
        "src/main.cts",
        "src/test/cjs-color-format.test.cts",
        "src/lib/ansi-static.ts",
        "src/lib/color-log.ts",
        "src/lib/ansi.ts"
      ],

      "exclude": ["node_modules", "**/*.mts"],

      "compilerOptions": {
        // CommonJS + Node-8 + ES5 (Early Support)
        "target": "ES5",
        "module": "CommonJS",
        "moduleResolution": "node",
        "esModuleInterop": true,
        // STRUCTURE
        "outDir": "builds/CommonJS",
        "declarationDir": "types",
        "rootDir": "src",
        "sourceRoot": "src",
        "composite": true,
        // EMISSIONS
        "declaration": true,
        "declarationMap": true,
        "sourceMap": true,
        "inlineSources": true,
        "noEmitOnError": true,
        "noEmit": false,
      }
    }

Believe it or not, I removed all the type checks and other stuff not required to get all intellisense features supported that I could, and emit a dual esm & cjs project.

Anyways, hopefully this helps




Carrot answered 10/10, 2022 at 8:23 Comment(3)
One question comes to mind when reading this. You say I dont specify a build command for ESM and cjs. But I dont have to do this because vite does this already. Take a look at the vite.config.js aswell.Hippodrome
Thank you, this is insanely detailed :) you should make this into a blog post!Despumate
If you're using the exports mapping, you can also use types under each map. In that case, the top-level types key is not necessary anymore. I actually wonder if it is even necessary if you just do "./*": "./dist/*".Wilie
C
3

I want to point my package.json exports['.'].import key at my lib directory (where my source lives) and have done with it.

That works just fine, except that Intellisense breaks in VS Code.

This happens because VS Code is parsing package.json to figure out where your typedefs are, and apparently VS Code has not yet caught up to the new export-mapping syntax!

Remember the main key? It still works, and if you use it alongside exports, Node.js will just ignore it. But VS Code will pick it up and use it to power your Intellisense!

For example:

{
  ...,
  "exports": {
    ".": {
      "import": "./lib/index.js", // where I write my code
      "require": "./dist/default/lib/index.js" // Babel transpiler target
    }
  },
  "main": "./lib/index.js", // where VS code looks for Intellisense
  ...
}
Contain answered 29/1, 2023 at 6:3 Comment(1)
Any idea how to get intellisense to pick up import filename from 'foo/filename' while using the export-mapping syntax for your package? In VS Code you can't right click to go to the source definition for these files if your export mapping points to somewhere other than the root of your package.Duky

© 2022 - 2024 — McMap. All rights reserved.