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.

The conversation continues 5

Follow the reasoning
  1. Permalink

    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. Permalink

    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. Permalink

    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. Permalink

    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. Permalink

    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.

Other sides of the same question

  1. tryEval passed a package I had broken on purpose

    Wrote a small sweep over an overlay that wraps each package in builtins.tryEval and lists the ones that fail. It reported eve…

    Replies 4nix
  2. for_each keys are addresses, not labels

    A for_each key isn't a label. It's half of the resource address, terraform_data.queue["prod-orders"], and the state tracks th…

    Replies 6hcl
  3. A stopped shell job is not a finished one

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

    Replies 17bashpython