next.js icon indicating copy to clipboard operation
next.js copied to clipboard

[NEXT-779] next/* - Typescript cannot find module when moduleResolution=nodenext and type=module

Open Izhaki opened this issue 2 years ago • 16 comments

Verify canary release

  • [X] I verified that the issue exists in the latest Next.js canary release

Provide environment information

Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 21.6.0: Thu Sep 29 20:13:56 PDT 2022; root:xnu-8020.240.7~1/RELEASE_ARM64_T6000
Binaries:
  Node: 16.17.0
  npm: 8.15.0
  Yarn: N/A
  pnpm: 7.25.0
Relevant packages:
  next: 13.1.7-canary.18
  eslint-config-next: 13.0.0
  react: 18.2.0
  react-dom: 18.2.0

Which area(s) of Next.js are affected? (leave empty if unsure)

TypeScript

To Reproduce

  • pnpm create next-app (with typescript)
  • add "type": "module" to package.json
  • change moduleResolution to nodenext
  • run npx tsc --noEmit

Describe the Bug

Typescript complain about next/*, for example:

Cannot find module 'next/head' or its corresponding type declarations.

I believe this is because there is no exports field in node_modules/next/package.json.

Expected Behavior

Typescript shouldn't complain.

Context

  • We use "type": "module" as we want repo scripts to be esm rather than cjs.
  • We use "moduleResolution": "nodenext" as we use a setup similar to the monorepo created by npx create-turbo@latest, but we want sources to be under src and not the root folder.

Which browser are you using? (if relevant)

No response

How are you deploying your application? (if relevant)

No response

NEXT-779

Izhaki avatar Feb 18 '23 00:02 Izhaki

Potential fix https://github.com/vercel/next.js/issues/46676#issuecomment-1451720255

balazsorban44 avatar Mar 06 '23 15:03 balazsorban44

Doesn't look like it:

image

And even if you remove the other export statement:

image

Izhaki avatar Mar 06 '23 23:03 Izhaki

Interesting, it worked for me in a "module": "node16" app (this is in tsconfig.json), as noted in #46676 (ESM, also using "type": "module" in package.json)

Maybe @andrewbranch has a tip here... 🤔

Or maybe this issue is not a duplicate of #46676 cc @balazsorban44

karlhorky avatar Mar 07 '23 06:03 karlhorky

Someone correct me if I'm wrong, but on the latest canary there is neither module nor exports in package.json. Also, I cannot see any ESM build under node_modules/next.

So I can't quite see why typescript should resolve the package...

I might be wrong, but it'll need an ESM build and exports field in package.json for this to work.

{
  "name": "next",
  "version": "13.2.4-canary.5",
  "description": "The React Framework",
  "main": "./dist/server/next.js",
  "license": "MIT",
  "repository": "vercel/next.js",
  "bugs": "https://github.com/vercel/next.js/issues",
  "homepage": "https://nextjs.org"
  // ...
}

Izhaki avatar Mar 07 '23 09:03 Izhaki

Take a look at this comment, if you haven't read #46676 completely yet:

  • https://github.com/vercel/next.js/issues/46676#issuecomment-1451731653

Are The Types Wrong shows that the next package is not bundled properly to be consumed by node16 moduleResolution in an ESM project.

The next package has incorrect types for the new TypeScript module formats.

karlhorky avatar Mar 07 '23 10:03 karlhorky

The next package has incorrect types for the new TypeScript module formats.

Yep. I just wonder if this isn't the second problem needs solving; the first one being "Cannot find module"?

Izhaki avatar Mar 07 '23 10:03 Izhaki

Replacing next/some-entrypoint with next/some-entrypoint.js works, but it’d be great to avoid it. If next adds exports into package.json, it will be possible to use next/some-entrypoint consistently. You can find an example of a workaround (next/some-entrypoint.js) in 🐸 https://github.com/kachkaev/njt/pull/186.

We can get inspiration from @vercel/analytics/package.json, which contains exports and works fine in moduleResolution=nodenext + type=module. I did not have to replace @vercel/analytics/react with @vercel/analytics/react in the above PR.

kachkaev avatar Mar 08 '23 11:03 kachkaev

I had a similar problem with Hono, but I solved it by changing the types directory to CJS.

https://github.com/honojs/hono/pull/747

taishinaritomi avatar Mar 20 '23 15:03 taishinaritomi

Replacing next/some-entrypoint with next/some-entrypoint.js works, but it’d be great to avoid it

I've been using these imports with .js suffixes (eg. next/image.js, next/server.js, next/link.js, etc), and it's worked so far.

However, one big footgun with this which I just ran into (pretty hard to debug) is if you import next/link.js, the feature of TypeScript type checking on Link[href] using typedRoutes: true by @shuding just fails silently:

import Link from 'next/link.js'; // ✅ import works with ESM + Node16 module resolution

<Link href="/unknown"> // ❌ href silently not checked, because `next/link` module in generated type declarations in .next/types/link.d.ts

The offending line in the .next/types/link.d.ts file looks like this:

declare module 'next/link' {

karlhorky avatar Mar 29 '23 12:03 karlhorky

Submitted a pull request.

Until that is merged/released, here is a local patch that you can use:

diff --git a/package.json b/package.json
index bfa500ed3b8526117c602e6ee9cc2aef847c317e..9025f24c698f89f231fd47da901d9ebc2c2f29cd 100644
--- a/package.json
+++ b/package.json
@@ -99,6 +99,92 @@
     "react-dom": "^18.2.0",
     "sass": "^1.3.0"
   },
+  "exports": {
+    ".": {
+      "import": "./index.js",
+      "types": "./index.d.ts"
+    },
+    "./amp": {
+      "import": "./amp.js",
+      "types": "./amp.d.ts"
+    },
+    "./app": {
+      "import": "./app.js",
+      "types": "./app.d.ts"
+    },
+    "./babel": {
+      "import": "./babel.js",
+      "types": "./babel.d.ts"
+    },
+    "./cache": {
+      "import": "./cache.js",
+      "types": "./cache.d.ts"
+    },
+    "./client": {
+      "import": "./client.js",
+      "types": "./client.d.ts"
+    },
+    "./config": {
+      "import": "./config.js",
+      "types": "./config.d.ts"
+    },
+    "./document": {
+      "import": "./document.js",
+      "types": "./document.d.ts"
+    },
+    "./error": {
+      "import": "./error.js",
+      "types": "./error.d.ts"
+    },
+    "./font": {
+      "import": "./font/index.js",
+      "types": "./font/index.d.ts"
+    },
+    "./font/google": {
+      "import": "./font/google.js",
+      "types": "./font/google.d.ts"
+    },
+    "./font/local": {
+      "import": "./font/local.js",
+      "types": "./font/local.d.ts"
+    },
+    "./head": {
+      "import": "./head.js",
+      "types": "./head.d.ts"
+    },
+    "./headers": {
+      "import": "./headers.js",
+      "types": "./headers.d.ts"
+    },
+    "./image": {
+      "import": "./image.js",
+      "types": "./image.d.ts"
+    },
+    "./link": {
+      "require": "./link.js",
+      "types": "./link.d.ts"
+    },
+    "./navigation": {
+      "import": "./navigation.js",
+      "types": "./navigation.d.ts"
+    },
+    "./router": {
+      "import": "./router.js",
+      "types": "./router.d.ts"
+    },
+    "./script": {
+      "import": "./script.js",
+      "types": "./script.d.ts"
+    },
+    "./server": {
+      "import": "./server.js",
+      "types": "./server.d.ts"
+    },
+    "./web-vitals": {
+      "import": "./web-vitals.js",
+      "types": "./web-vitals.d.ts"
+    }
+  },
   "peerDependenciesMeta": {
     "node-sass": {
       "optional": true

lucgagan avatar May 25 '23 22:05 lucgagan

Since nextjs itself is a js bundler, the preferred moduleResolution should be bundler IMO. That would solve all the problem, however nextjs does not support bundler and would overwrite it back to node ...

louisgv avatar May 28 '23 18:05 louisgv

ref: https://github.com/vercel/next.js/pull/50289

louisgv avatar May 28 '23 18:05 louisgv

FYI, this went away for me when I changed these settings in my tsconfig.json:

(Read more here: https://www.totaltypescript.com/tsconfig-cheat-sheet)

michaelhays avatar Sep 18 '23 19:09 michaelhays

Note that nodenext and bundler are fundamentally different module resolution algorithms.

With this context, we’re ready to begin answering your question directly. The biggest, most noticeable difference between --module nodenext and --module esnext is that the former implies --moduleResolution nodenext, a new resolution mode designed for Node’s specific implementation of co-existing ESM and CJS, while the latter does not imply a moduleResolution setting because there is no such corresponding setting in TypeScript right now. Put another way, when you say you’re using --module esnext, you’re allowed to write, and we will emit, the latest and greatest ES module code constructs, but we will not do anything differently with deciding how imports resolve. You’ll likely continue using --moduleResolution node, which was designed for Node’s implementation of CJS. What does this mean for you? If you’re writing ESM for Node, you can probably make some stuff work with --module esnext and --moduleResolution node, but newer Node-specific features like package.json exports won’t work, and it will be extremely easy to shoot yourself in the foot when writing import paths. Paths will be evaluated by tsc under Node’s CJS rules, but then at runtime, Node will evaluate them under its ESM rules since you’re emitting ESM. There are significant differences between these algorithms—notably, the latter requires relative imports to use file extensions instead of dropping the .js, and index files have no special meaning, so you can’t import the index file just by naming the path to the directory.

– https://stackoverflow.com/a/71473145/368691

nodenext needs to be supported by Next.js. Otherwise this causes major maintenance headache for organizations with large monorepos.

gajus avatar Nov 01 '23 23:11 gajus

As an alternative to @lucgagan suggestion above, adding wildcard exports to package.json works for me:

{
  ...
  "exports": {
    ".": "./index.js",
    "./*": "./*.js",
    "./font/*": "./font/*/index.js",
    "./dist/*": "./dist/*/index.js"
  }
}

Node's support for subpath patterns is documented here.

parcelgraph avatar Nov 08 '23 18:11 parcelgraph

EDIT: sorry, just realized this is similar to this comment

In nodenext we have:

ts: 'Link' cannot be used as a JSX component.

The workaround is:

import Link from "next/link.js";

<Link.default href="" />

However, this does not work (still needs Link.default):

import { default as Link } from "next/link.js";

<Link href="" />

Adding

+ export { Link }
export default Link

to next/link.d.ts works:

import { Link } from "next/link.js";

<Link href="" />

I guess the wildcard export is not working:

export * from './dist/client/link'
import * as Link from 'next/link' // only contains `Link.default`

llllvvuu avatar Jan 31 '24 23:01 llllvvuu

FYI, this went away for me when I changed these settings in my tsconfig.json:

(Read more here: https://www.totaltypescript.com/tsconfig-cheat-sheet)

You missed the point. OP is trying to have a working ESM+Typescript setup so he needs NodeNext in both.

@lucgagan and @parcelgraph your solutions might be a start but it is not enough. The project needs to create correct .d.ts build outputs as well. For example:

Userland code:

import Document from 'next/document';

will not work even with your export changes, since the build output from next is CJS. For ESM+TS to work the dist folder need to contain .mjs files containing code employing import/export syntax (no require!), and .d.mts files.

Today the build output only contains CJS format, i.e. .js files employing module.exports/require syntax, and .d.ts files. This type of output only works for non-ESM projects.

Solution

The complete solution to this problem summarized in a single sentence is to emit both CJS and ESM build outputs in the published artefact, and point the package.json to the correct locations, so the artefact is 100% backwards compatible with existing CJS projects while still working correctly with new ESM projects.

In other words, instead of outputting a single build into dist/* maintainers should ALSO export a ESM build to e.g. dist/esm/* (in addition to the existing CJS build). The contemporary tool for this is esbuild, it can be fine to use directly but there are also high level tools for making this easier, such as tsup to help do maintainers control this step and make it more maintainable through configuration.

So anyway, when the ESM+CJS build step is working, the package.json should point to the respective output folders:

  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/esm/index.mjs", // ./dist/esm/index.d.mts will be found automatically
      "require": "./dist/index.js"
    },
    "./document": {
      "import": "./dist/esm/document.mjs", // ./dist/esm/document.d.mts will be found automatically
      "require": "./dist/document.js"
    },
    ... // etc
  • main points to CJS artefact
  • types points to CJS type defs
  • exports explicitly points users where to find the file using deep imports e.g import Document from 'next/document'

Then it will work* and everyone will be happy.

*) To reiterate, the ESM build output must use full path import syntax e.g.

import Gateway from './gateway/gateway.mjs';
import { Params } from './types.mjs';

and that's why esbuild and/or tsup is so important since tsc cannot write this type of output afaik.

klippx avatar May 28 '24 07:05 klippx

@klippx thank you for your wonderful explanation. We terminated work on our CJS -> ESM conversion after only a few hours, instead of spending days on trying to fix our already too complicated setup.

I'm interpreting this as that next.js isn't ready for TS ESM, and that it won't be until the above things are fixed.

olasundell avatar Jul 01 '24 12:07 olasundell