Skip to content

Subpath imports in ESM mode #688

@stazz

Description

@stazz

🐛 Bug report

Please notice that this problem actually spans a wide range of -ts packages. At least the following are affected

  • fp-ts,
  • io-ts,
  • io-ts-types,
  • monocle-ts, and
  • newtype-ts

Current Behavior

If using the following subpath import:

import * as errorReport from "io-ts/PathReporter";

And then running using ts-node with --esm flag, or ESM-based bundler (e.g. Vite), while things look OK in IDE, at runtime, there will be error:

xyz/node_modules/ts-node/dist-raw/node-internal-modules-esm-resolve.js:352
     throw new ERR_MODULE_NOT_FOUND(
           ^
 CustomError: Cannot find module '/xyz/node_modules/io-ts/PathReporter' imported from /xyz/my-file.ts

This happens because there is no matching entry for PathReporter subpath in package.json file of io-ts package.

Note that everything works fine when in CJS mode.

Expected behavior

I expect subpath imports of io-ts behave successfully at both compile- as well as runtime, both with CJS modules and ESM modules.
Without any additional setup.

Reproducible example

Create package.json with io-ts dependency:

{
  "name":  "io-ts-bug-repro",
  "private": true,
  "version": "1.0.0",
  "dependencies": {
    "fp-ts": "2.13.1",
    "io-ts": "2.2.20"
  },
  "devDependencies": {
    "ts-node": "10.9.1",
    "typescript": "4.9.5"
  }
}

Add basic TS configuration to tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "module": "es2022",
    "moduleResolution": "node",
    "lib": [
      "ES2022"
    ],
    "target": "ES2022"
  },
  "ts-node": {
    "esm": true,
    "experimentalSpecifierResolution": "node",
  }
}

Create file src/index.ts with the following code:

import * as errorReport from "io-ts/PathReporter";

const testing = errorReport.success();

and try to execute it:

ts-node src

Suggested solution(s)

It looks like a whole family of -ts packages should be migrated to ESM era. The current setup is extremely weird, lacking "type": "module" entry from package.json files, and duplicating .d.ts files, which also use /lib or /es6 imports interchangeably (sometimes ES6 things including from xyz-ts/lib/abc). This results subpath imports for -ts packages being completely unuseable in ESM.

There are I guess many approaches to solve this. I think it is important to retain CJS functionality still, but also enable everything work for ESM without additional setup. I personally flavor this pattern, which I found to be working for all my packages (note that order of "types", "import", and "require" in the file is meaningful!). I keep .d.ts files in their own dist-ts folder, all the CJS-flavored .js files in their own dist-cjs folder, and finally all ESM-flavored .js files in their own dist-esm folder. I ended up with this setup after long experiments with subpath imports, both outside of module, and within.

{
  "name": "package-usable-by-both-cjs-and-esm",
  "type": "module",
  "main": "./dist-cjs/index.js",
  "module": "./dist-esm/index.js",
  "types": "./dist-ts/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist-ts/index.d.ts",
      "import": "./dist-esm/index.js",
      "require": "./dist-cjs/index.js"
    }
  }
}

If I want to enable subpath export for e.g. my-subpath, I need to first modify the exports in package.json:

{
  "exports": {
    ...,
    "./my-subpath": {
        "types": "./dist-ts/my-subpath.d.ts",
        "import": "./dist-esm/my-subpath.js",
        "require": "./dist-cjs/my-subpath.js"
    }
  }
}

And then create the following stub package file to ./my-subpath/package.json path within distributed NPM package (this is only for CJS support):

{
  "type": "module",
  "main": "../dist-cjs/my-subpath.js",
  "module": "../dist-esm/my-subpath.js",
  "types": "../dist-ts/my-subpath.d.ts"
}

Technically, only main property is needed, but the others are just in case.

This setup allows the same subpath imports always to work, no matter whether they are in TS, CJS, or ESM mode. Furthermore, it adhers to DRY principle, so that it doesn't duplicate .d.ts files.

Additional context

Your environment

  • Which versions of io-ts are affected by this issue?
    • All of them, AFAIK.
  • Did this work in previous versions of io-ts?
    • No.
Software Version(s)
io-ts 2.2.20
fp-ts 2.13.1
TypeScript 4.9.5

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions