turf icon indicating copy to clipboard operation
turf copied to clipboard

Support Typescript's moduleResolution: "node16"

Open amiller-gh opened this issue 2 years ago • 4 comments

This is a bug in at least @turf/[email protected] and potentially other packages.

Typescript has recently released a new moduleResolution option that when set to node16 or nodenext will use new ESM resolution logic. This includes an understanding of the new "exports" package.json field.

Any module in this monorepo that currently uses the exports field (like @turf/envelope) must now specify the types in the export config as well, otherwise Typescript will complain that it can't find the types file if you turn on moduleResolution: "node16".

E.g. Turn this:

{
  "main": "dist/js/index.js",
  "module": "dist/es/index.js",
  "exports": {
    "./package.json": "./package.json",
    ".": {
      "import": "./dist/es/index.js",
      "require": "./dist/js/index.js"
    }
  },
  "types": "index.d.ts",
}

Into this:

{
  "main": "dist/js/index.js",
  "module": "dist/es/index.js",
  "exports": {
    "./package.json": "./package.json",
    ".": {
      "types": "./index.d.ts",
      "import": "./dist/es/index.js",
      "require": "./dist/js/index.js"
    }
  },
  "types": "index.d.ts",
}

Until then, using this package in Typescript with the new module resolution scheme results in the error TS7016: Could not find a declaration file for module '@turf/envelope'.

amiller-gh avatar Jun 29 '22 02:06 amiller-gh

Hey @amiller-gh, I think it's fair to say we're keen to support TypeScript as best we can (we are currently slowly converting Turf modules to TypeScript). This seems reasonably straight forward to resolve - do you have any links to any surrounding information/context? I've looked at the TypeScript page on moduleResolution but I didn't see any mention of the exports fields/types.

JamesLMilner avatar Jun 30 '22 10:06 JamesLMilner

+1, seems like TypeScript doesn't know where the typings is when using ES Module.

yukha-dw avatar Aug 15 '22 01:08 yukha-dw

This seems reasonably straight forward to resolve - do you have any links to any surrounding information/context? I've looked at the TypeScript page on moduleResolution but I didn't see any mention of the exports fields/types.

exports.x.types seems to be a community definition:

"types" - can be used by typing systems to resolve the typing file for the given export. This condition should always be included first. https://nodejs.org/docs/latest-v16.x/api/packages.html#community-conditions-definitions

Firebase Admin uses it in its exports packaje.json key in addition to types: https://github.com/firebase/firebase-admin-node/blob/v11.0.1/package.json#L112-L128 https://github.com/firebase/firebase-admin-node/blob/v11.0.1/package.json#L62

AngularFire seems to use typings key instead: https://github.com/angular/angularfire/blob/7.4.1/package.json#L131

Chalks ES uses only types, but the type definition is in the same directory of the main exports: https://github.com/chalk/chalk/blob/v5.0.1/package.json#L18

nelson6e65 avatar Sep 02 '22 12:09 nelson6e65

I tried to hard-code my node_modules/@turf/turf/package.json adding types to tests this. It seems to "work", but I got a bunch of "Could not find a declaration file for module '@turf/XXXXX'" errors. 😅

nelson6e65 avatar Sep 02 '22 12:09 nelson6e65

import { centerOfMass } from "@turf/turf";

getting the error declaration not found. any solutions?

bonesoul avatar Jan 09 '23 12:01 bonesoul

@JamesLMilner the documentation you're looking for to fix this issue is here in the Typescript docs. It should be as simple as adding a types field to the exports

deloreyj avatar Jan 25 '23 00:01 deloreyj

Adding "types": "./index.d.ts" fixes things for me temporarily. But this should be done on @turf side.

"exports": {
  "./package.json": "./package.json",
  ".": {
    "types": "./index.d.ts",
    "import": "./dist/es/index.js",
    "require": "./dist/js/index.js"
  }
},

evgenyt1 avatar Feb 03 '23 13:02 evgenyt1

I made a PR on this. Pretty straightforward.

evgenyt1 avatar Feb 20 '23 07:02 evgenyt1

I made a temp patch usable through pnpm's patchedDependencies and similar @[email protected]

evgenyt1 avatar Feb 20 '23 07:02 evgenyt1

An alternative patch that will also partially satisfy https://github.com/arethetypeswrong/arethetypeswrong.github.io. I also highly recommend @Turf using ATTW CLI or website to verify that their bundle works properly with both CJS and ESM. Usage is pretty dead simple, see the readme on their repo.

Note that the patch below is a partial satisfaction because officially you're supposed to ship a different types file for CJS as opposed to ESM, i.e. index.d.cts and index.d.ts/index.d.mts. This is related to how for CJS you would likely use export = MyPackage for proper interop with const MyPackage = require('my-package') whereas for ESM you would use export default MyPackage for proper interop with import MyPackage from 'my-package';

I'm also going to summon @andarist and @andrewbranch here whom are both experts in the field of properly defining ESM/CJS/types and have helped me understand so much about this whole topic.

diff --git a/package.json b/package.json
index 1946d013646f884c74f2d5c7bf94d282154accdc..17cdc18d1858018ea9d41190c7ba53da3788b6d8 100644
--- a/package.json
+++ b/package.json
@@ -48,8 +48,14 @@
   "exports": {
     "./package.json": "./package.json",
     ".": {
-      "import": "./dist/es/index.js",
-      "require": "./dist/js/index.js"
+      "import": {
+        "types": "./index.d.ts",
+        "default": "./dist/es/index.js"
+      },
+      "require": {
+        "types": "./index.d.ts",
+        "default": "./dist/js/index.js"
+      }
     }
   },
   "browser": "turf.min.js",

favna avatar Nov 21 '23 09:11 favna

The proposed patch above doesn't look correct to me because it tries to reuse the same declaration file for both formats. A single file can only be interpreted either as a module or as a script. You want two files (often even with the same content) so they can independently represent your module (import condition) and your script (require condition). A single file can't be used for both without issues because it's ambiguous. In this particular example, it would be interpreted as a script because this package.json doesn't set "type": "module" so all .js files in this "scope" (and .d.ts runs on the same rules as .js) are treated as scripts.

You could try to copy over your index.d.ts to those es and js directories and point types condition to those copies. This could work because then that /dist/es/index.d.ts would be located in a directory that has an extra package.json in it, one that sets "type": "module". So that would make that .d.ts to be interpreted correctly as a module - whereas the .d.ts in the other directory would continue to represent the script.

Andarist avatar Nov 21 '23 10:11 Andarist

Hi all. Incoming PR soon to hopefully address this issue and more. Would appreciate your input on the direction. Aiming to support both CJS and ESM.

Please have a look at the below and let me know (in theory) how it looks:

tsconfig.shared.json:

{
  "compilerOptions": {
    "target": "es2017",
    "module": "node16",
    "declaration": true,
    "esModuleInterop": true,
    "strict": true,
    "moduleResolution": "node16",
    "importHelpers": true,
    "skipLibCheck": true
  }
}

Contents of dist/ directory in each package generated by tsup:

  • index.js
  • index.d.ts
  • index.mjs
  • index.d.mts

The two d.ts files are identical, though duplicated as trying to reuse one for both CJS and ESM seems to be the source of some problems.

package.json extract

  "main": "dist/index.js",
  "module": "dist/index.mjs",
  "exports": {
    "./package.json": "./package.json",
    ".": {
      "require": "./dist/index.js",
      "import": "./dist/index.mjs"
    }
  },

No "type": "module" config item so CJS is default. No explicit d.ts entry points defined - taking a less is more approach and instead leaving it to the tooling or IDE to infer based on the location of the implementation file.

Retro-testing a few existing issues seems promising so far. Any obvious gotchas or oddities?

smallsaucepan avatar Dec 16 '23 00:12 smallsaucepan

No explicit d.ts entry points defined - taking a less is more approach and instead leaving it to the tooling or IDE to infer based on the location of the implementation file.

I would highly recommend against this. It's really easy to add the entrypoints and there is 0 downsides. Refer to my comment above for how to define .d.ts files for both import and require.

I have recently fixed up all of the Sapphire projects that I maintain so you can also take a looksie here

  • tsup.config.ts that does ESM and CJS (also IIFE, but ignore that part): https://github.com/sapphiredev/shapeshift/blob/main/tsup.config.ts
  • package.json extract: https://github.com/sapphiredev/shapeshift/blob/main/package.json#L7-L23 (again, ignore the browser, and unpkg fields, they are for the IIFE bundle) Other Sapphire projects on the same GH org are also updated for other references.

favna avatar Dec 16 '23 01:12 favna

Thanks @favna. Really appreciate it. Based on your advice I will:

  • re-split dist into esm/ and cjs/ subdirs. Probably just my personal preference for avoiding too many hierarchy layers creeping in
  • include in the package.json exports config d.ts entry points

I notice in Sapphire package.json you don't set the type explicitly to commonjs (implied as the default node interpretation currently yes?). What do you think about the advice at the end of this topic to always declare the module type explicitly? Any reason not to?

This flag currently defaults to "commonjs", but it may change in the future to default to "module".

smallsaucepan avatar Dec 16 '23 01:12 smallsaucepan

Hmm so there is a very basic reason I don't have that and that's that I just didn't properly look into it yet.

That said, similar to the monorepos sapphiredev/utilities and sapphiredev/plugins I also maintain https://github.com/skyra-project/archid-components/ which while being primarily ESM only (some of the packages don't even support CJS), all packages do have "type": "module" so maybe it can be added for other packages as well. A small word of warning though, if you set "type": "module" then tsup will read this and change some of the file extensions. You can see here https://github.com/skyra-project/archid-components/blob/main/packages/env-utilities/package.json that .d.ts is then used for the ESM types and the CJS types get .d.cts as opposed to respectively .d.mts and .d.ts. Setting "type": "commonjs" however retains the types are you already applied them because as you quote, that is the current default.

The one thing that bothers me a bit with setting "type": "commonjs" is when the following tsconfig options are configured:

{
  "compilerOptions": {
    "module": "node16",
    "moduleResolution": "node16",
    "verbatimModuleSyntax": true,
    "noEmit": true,
    "strict": true
  }
}

Now running tsc on the code (for type checking only, this is part of our CI/CD pipeline) will throw many of these kinds of errors:

src/scheduled-tasks/postStats.ts:4:10 - error TS1286: ESM syntax is not allowed in a CommonJS module when 'verbatimModuleSyntax' is enabled.

4 import { Status } from 'discord.js';
           ~~~~~~

src/scheduled-tasks/postStats.ts:12:1 - error TS1287: A top-level 'export' modifier cannot be used on value declarations in a CommonJS module when 'verbatimModuleSyntax' is enabled.

12 export class PostStatsTask extends ScheduledTask {

In a sense valid because the package.json is saying you're writing CJS code, but annoying nonetheless. Now the fix for that is easy and that is to change to tsconfig to:

{
  "compilerOptions": {
-    "module": "node16",
-    "moduleResolution": "node16",
+   "module": "es2022",
+   "moduleResolution": "bundler",
    "verbatimModuleSyntax": true,
    "noEmit": true,
    "strict": true
  }
}

which indicates to TypeScript that you're compiling with a bundler (i.e. tsup -> esbuild) so maybe I should just fully embrace this and set the field anyway but I would still want to do some proper research into if it has any other unforeseen downsides.


Lastly regarding this point:

re-split dist into esm/ and cjs/ subdirs. Probably just my personal preference for avoiding too many hierarchy layers creeping in

Truth be told I'm not the biggest fan either but I'm even less a fan of mixing CJS and ESM files in the same directory (and our @sapphire/framework would go completely badonkadonk if it would because it reads files from the file system) so as long as we're compiling for 2 ecosystems I'd say it's the best solution we got.

And as for this one:

include in the package.json exports config d.ts entry points

Yeah I totally get it. I just never had any request from a community member or reason otherwise to expose the package.json that way. As far as getting the version goes I created an esbuild plugin (usable with tsup) for that purpose and it serves me and other users well https://github.com/favware/esbuild-plugin-version-injector

favna avatar Dec 16 '23 13:12 favna

Thanks @favna. Would definitely like to enable verbatimModuleSyntax down the track. Aiming to also add arethetypeswrong to the CI pipeline too.

Would you mind casting an eye over PR #2555? If there aren't any glaring deficiencies we'll aim to release the next alpha once that's merged.

smallsaucepan avatar Dec 17 '23 06:12 smallsaucepan