openlang

ESM default import, CommonJS types

Got stuck importing a sibling package from the ESM side of a monorepo.

@repo/ui has no "type" field in its package.json, and its exports looks like this. "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs". The consumer is "type": "module" with moduleResolution set to nodenext.

Okay, I write import ui from '@repo/ui' and type ui(. The editor says the value isn't callable. I type ui. and completion offers default. (That part made me laugh.) Switch to ui.default() and the type check passes, but at run time Node takes the import condition and loads the real ESM file, which has no such property.

Rereading the handbook's modules reference, a .d.ts counts as CommonJS when the nearest package.json has no "type": "module". So the types describe a CJS module while the runtime loads an ESM one. A default import of CJS from ESM gets the whole module.exports, hence the extra layer of default.

What's the cleanest fix here? I'm torn between a declaration file per condition and just turning the package into ESM.

后续讨论 5

顺着彼此的依据读下去
  1. 固定链接

    Go with a declaration file per condition. Nest types inside each condition so the extension fixes the format and nothing hangs on the package's "type" field anymore.

    "exports": {
      ".": {
        "import": {
          "types": "./dist/index.d.mts",
          "default": "./dist/index.mjs"
        },
        "require": {
          "types": "./dist/index.d.cts",
          "default": "./dist/index.cjs"
        }
      }
    }

    The modules reference in the handbook nests them the same way in its example.

    The scarier part of your current setup is that ui.default() type checks. The type checker only knows the module the declaration file describes. Which file Node picks at run time is invisible to it. Bend the code toward wrong types and the error just moves to run time.

    Converting the whole package to ESM is fine too. If anything still goes through the require side, though, its types need their own care.

  2. 固定链接

    Tess's way fixes the ESM side. The build emits index.d.mts and index.d.cts, and exports is split per condition. ui( gets no complaint, default is gone from completion, and --traceResolution shows the import condition landing on index.d.mts.

    Watch the other condition, though. If the build just copies the same declaration into index.d.cts, export default and all, an old .cts script that does import ui = require('@repo/ui') type checks ui.default(), while the CJS bundle assigns the function straight to module.exports. At run time ui.default is undefined. The original bug, mirrored onto the other condition.

    The CJS declaration needs its own shape.

    declare function ui(): string;
    export = ui;

    That way both sides line up. It also means the build script writes two different declaration files instead of copying one, which is an extra step I'm just going to live with.

  3. 固定链接

    Two more things worth checking.

    First, condition order. The Node docs say "types" should always come first, and conditions are matched in the order they're written in the object. Your current layout gets that right, but once you split per condition, "types" has to lead inside each block as well.

    Second, whether the consuming package's tsconfig has references. With project references, modules imported from a referenced project load the .d.ts it produced, and tsc --build rebuilds out-of-date projects in dependency order first. Without them, the compiler can read a stale declaration file left in dist and nobody notices.

    Keep package.json dependencies and references drawing the same graph and this kind of problem shows up early, usually as a build error instead of a confusing editor.

  4. 固定链接

    Only siblings use it? Drop the CJS build, and the second declaration file leaves with it. Then hunt the one script still calling require.

  5. 固定链接

    jo_ren, your references comment was it. The consuming tsconfig had no references at all. Which explains why, right after I first edited package.json, the editor kept showing the old index.d.ts for a while. I spent a good stretch convinced my fix was wrong.

    Order was already fine, so that part wasn't it. Added the references, ran tsc --build once, dist got refilled, and only then did the types on screen match the change.

同一个问题的其他角度

  1. Launching the consumer on a stopped event

    Got a question about a supervisor rule that treats a stopped process as a finished one, and a tiny repro makes the problem ob…

    回复 17最近活动 bashpython
  2. What does a --hash line actually pin?

    A requirements file with --hash on every line installs cleanly on my laptop and fails with a hash mismatch in a fresh Linux c…

    回复 8最近活动 python
  3. After the Kotlin DSL move, subprojects can no longer find implementation

    Moving an old multi-project build from the Groovy DSL to the Kotlin DSL. The root build.gradle had this block. The root I con…

    回复 2最近活动 kotlingroovy