Sergey Nikitin
Sergey Nikitin

Reputation: 533

Typescript packages that ship with .mjs and .d.ts, but without .d.mts - how to import with ESM enabled?

Intro

With ECMAScript Module support added in Typescript 4.7, several new file extensions may get involved during a TS build including .mjs, .d.mts. If a project has it enabled this, TS compiler has more complexity to manage when it does module resolution (locates the files to import). With the new ESM file extensions there are two straightforward kinds of modules:

  1. a module has .js implementation, .d.ts declaration file
  2. a module has .mjs implementation, .d.mts declaration file

Question

Not all packages fit the above categories. Some packages ship with both .js and .mjs versions of implementation but just the .d.ts declaration file, without .d.mts

What are the resolution rules in this case? It seems .mjs gets prioritised over .js but refuses to work without .d.mts which is problematic if you don't own the imported module. Can this be resolved without modifying the package?


Example

For a project that has ESM enabled via the following configs

// package.json
"type": "module"

// tsconfig.json
"module": "Node16",
"moduleResolution": "node16"

that depends on a package (e.g. js-base64) which ships with .js, .mjs, .d.ts but no .d.mts

$ ls -l node_modules/js-base64
base64.d.ts
base64.js
base64.mjs

then when I try to import it like

// myfile.ts
import { Base64 } from 'js-base64'

I get an error:

Could not find a declaration file for module 'js-base64'. '/myproj/node_modules/js-base64/base64.mjs' implicitly has an 'any' type

However, if I do

$ ln -s node_modules/js-base64/base64.d.ts node_modules/js-base64/base64.d.mts

then the error goes away which suggests to me that .d.ts is deliberately ignored.

Upvotes: 19

Views: 30664

Answers (1)

AKUMA no ONI
AKUMA no ONI

Reputation: 11879

Getting Future Readers on the Same Page

The question above asks about a package.json file, that AToW (at the time of writing) was configured like this.

js-base64/package.json:
{
  "main": "base64.js",
  "module": "base64.mjs",
  "types": "base64.d.ts",

  "files": [
    "base64.js",
    "base64.mjs",
    "base64.d.ts"
  ],

  "exports": {
    ".": {
      "import": "./base64.mjs",
      "require": "./base64.js"
    },
    "./package.json": "./package.json"
  },
}

The Issue:


When the question's author would import the package file as an ESM module, he would use an import statement to attempt to access it, however, his "TypeScript Compiler" (aka TSC) would throw the error (displayed below) each time he tried to use the imported module.

Could not find a declaration file for module: "js-base64" '/myproj/node_modules/js-base64/base64.mjs' implicitly has an 'any' type


To Reiterate the Question


Can the module resolve without re-configuring the package. And why does creating a symbolic link, as shown is Sergey's question, seem to fix the error that is occurring?



The Issue in Greater Detail


This is a bi-modular Node package, meaning that it contains two builds, a CJS build, and a ESM build. It is configured to use different files only at the entry-point of the module, the rest of the package should (in theory) be the same.

The files configured for the two different entry points can be seen in the package.json snippet I posted at the very-top of this answer.

Before we get to far, I want to make it clear, the package.json files configuration has a single issue, however, for the most part, its configured correctly. The ESM & CJS entry points & builds all work well, the only problem is that when a TypeScript user imports the module as an ECMAS-Moduleaka ESM — it doesn't resolve typed, consequently, the TypeScript compiler throws a type error when the questions author attempts to import & use the package.


The Current Configuration's Implications

Not resolving with as a typed module has some obvious implications. Although, they are obvious to most, its good to cover them just to make sure all readers are grounded at the same point in the discussion.


  1. If your use pure JavaScript you likely won't notice anything is off. JS doesn't use types, and therefore, importing the package into a pure JavaScript code-base, should be able to be done — as an "ES-Module", or as a "Common-JS Module" — without experiencing any issues.

  2. Those using TypeScript should be able to resolve the module via an import { ... } from 'js-base64' &/or a require('js-base64') statement and everything should resolve being typed.

  3. Those trying to import the package using TypeScript — in an ESM environment — will get type errors, and they should notice that the package is not resolving with types.


Why won't the Types Resolve? Are they not included in the base64.d.ts file?

So, the package is written in TypeScript, generally a bi-modular package will be written in TypeScript, or at the very least, transpilled using some transpiler. In this case, the package is defiantly a TS authored package. I havn't inspected its code thoroughly but I wouldn't be surprised if it use to be pure JS, and was converted at some point to TS. Either way, it does have a properly TSC emitted .d.ts declaration file.


So why won't it resolve?

It isn't resolving because of how it has been configured. The package is configured, by default, to resolve as a "Common-JS Module". The base package.json file doesn't contain a "type" field, therefore it defaults to a CJS module. The package.json file's "type" field can be set to either...

  1. "commonjs" (aka CJS)   or,
  2. "module" (aka ESM)
As stated above, omitting the "type" field results in the package resolving as a CJS module.


Configuring modules for ESM, especially when attempting to support CJS at the same time, has become a very complex ordeal, and currently is not understood by many when it pertains to TypeScript written packages. This is because much of the support for ESM is new, despite the ESM standard being included in the ECMA-262 specification back in 2015.

The important thing to note is that the package-maintainer used the package.json file's "exports" field to define separate entry points for the two module types.

That is this bit of JSON-Code here:
  "types": "base64.d.ts",
  "exports": {
    ".": {
      "import": "./base64.mjs", // ESM entry point
      "require": "./base64.js" // CJS entry point
    },
    "./package.json": "./package.json"
  },

He could have done it the other way around, but he didn't, he did it the way you see above.



What a Solution Looks Like


The problem can be seen in the snippet above, the types field is where he tells TSC where the declaration file is when the package is imported as a module, the problem is, as I pointed out above, the package is defaulting to resolve as a CJS module, despite defining an ESM entry point.

To solve the issue, the package needs to have a declaration file that has the same exact name & file-extension as the file set as the ESM entry point. Another way to solve the issue or the already existing base64.d.ts declaration file needs to be explicitly configured to resolve with the file set as the ESM entry point, (which is base64.mjs).

I went in and adjusted things, to verify that what I am writing write now is cannon. One of the very first things I did when playing around with the package, was change the configuration set in the package.json file's "exports" field.


##### This is what the "exports" field should look like
// jD3V's adjusted package configuration

{
  "main": "base64.js",
  "module": "base64.mjs",
  "types": "base64.d.ts",

  "files": [
    "base64.js",
    "base64.mjs",
    "base64.d.ts"
  ],

  "exports": {
    ".": {
      "import": "./base64.mjs",
      "require": "./base64.js",
      "types": "./base64.d.ts"
    },
  },
}

Above you can see that the package is configured such that TypeScript can now infer (from the "types" field that's wrapped in the "exports" field) that the base64.d.ts file should be resolved with all exports.


He could have wrote the exports field like this:
Below is a bit I took from "TypeScript v4.7 Release Notes", the comments in the snippet are EXTREMELY HELPFUL. Please try to understand what the comments are trying to say.
    "exports": {
        ".": {
            // Entry-point for `import "my-package"` in ESM
            "import": {


            // Where TypeScript will look.
            "types": "./base64.d.ts",


            // Where Node.js will look.
            "default": "./base64.mjs"
        },

        "require": "./base64.js",
    },
  }

The difference between the JD3V package.json file, and the TS v4.7 snippet, is that the "import" field (in the "exports" field) takes an object as its assigned value, consequently; when the types location is set in the "TS v4.7" it is setting a specific file for "base64.mjs", where as in the JD3V snippet it sets a specific "types" location for all exports.


PLEASE NOTE: "As the package stands, using the configuration shown at the top of this answer, this module cannot be imported as an ESM module, into a TypeScript project, without fixing how the 'base64.d.ts' file resolves. As it currently doesn't resolve when the package is imported an ESM module."


Furthermore, this is all officially documented, and can be found at the 2 links below:

TS v4.7: "ECMAScript Module Support in Node.js"

TS-Lang: package.json Exports, Imports, and Self-Referencing

Upvotes: 26

Related Questions