TypeScript: This expression is not callable for ESM consuming CJS with default export

Bug Report

The package react-use-websocket is written in TypeScript and transpiled by TypeScript. It transpiles to CJS and exposes a default export.

When consume by a ESM project, TS errors out: This expression is not callable.

🔎 Search Terms

ESM CJS default export

🕗 Version & Regression Information

4.9.3 (likely from 4.7.0)

5.0.0-dev.20230103 also fails

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about ESM CJS

⏯ Playground Link

Repro link: https://github.com/cyberuni/ts-esm-on-cjs-with-default-export

đŸ’» Code

import useWebSocket from 'react-use-websocket'
// or import { default as useWebSocket } from 'react-use-websocket'
// or import * as ws from 'react-use-websocket'; ws.default(...)

useWebSocket('abc') // <-- This expression is not callable

tsconfig.json:

{
  "compilerOptions": {
    "allowSyntheticDefaultImports": true,
    "module": "Node16", // doesn't matter
    "moduleResolution": "Node16",
    "outDir": "dist",
    "strict": true,
    "target": "ES2019"
  },
  "include": [
    "ts",
    "types"
  ]
}

🙁 Actual behavior

This expression is not callable

🙂 Expected behavior

work

About this issue

  • Original URL
  • State: closed
  • Created a year ago
  • Reactions: 6
  • Comments: 19 (14 by maintainers)

Most upvoted comments

Really, react-use-websocket shouldn’t be providing an export named "default" in a CJS module, because it’s never (in a cromulent module configuration) going to be consumable from a default import form.

The problem is, that is exactly what tsc generates when compiling a default export. I have created https://github.com/cyberuni/typescript-module-resolutions-demo to demonstrate how tsc behaves.

IMO it is just wrong (or put in another way, some bugs through the workflow), that TypeScript cannot consume code generated by TypeScript.

btw, I just realized one thing, to specify a different typings for CJS when using the exports map, it needs to be done like this:

{
  "exports": {
    "require": {
      "types": "./cjs/index.d.ts",
      "default": "./cjs/index.js"
    },
    "import": {
      "types": "./esm/index.d.ts",
      "default": "./esm/index.js"
    }
  }
}

Unless you keep the main and types|typings field at the top-level. i.e. a hybrid approach:

{
  "exports": {
    "types": "./esm/index.d.ts",
    "import": "./esm/index.js"
  },
  "main": "./cjs/index.js",
  "types": "./cjs/index.d.ts"
}

There are cases that we want to support both CJS and ESM [
] That is not the case

Again I think we broadly agree. There are definitely places where you want to ship both CJS and ESM. No contention there. It’s also possible (though of course by no means trivial) to write a tool which can take a module written in one module system and, with proper configuration, produce a reasonably-similar module in the other system, subject to some constraints which can be met in most cases.

What I’m saying is, the tool that does this transformation is not TypeScript, or at least should not be. The CJS <-> ESM module problem is not one that TypeScript can uniquely solve, nor is it one that can only be solved in a TypeScript codebase, nor is it one that is currently unsolved. It’s a problem on the same tier as minification, downleveling, linting, bundling, gzipping, tree-shaking, and so on: those which the JavaScript ecosystem broadly needs, has, and is in no want of an N+1th solution. We’re trying to spend as much time as we can writing the world’s best static type system for JavaScript and not writing the world’s 43rd tool concerned with doing machine translation of JavaScript from one format to another.

That’s what “pick one runtime target” means – not that you can’t or shouldn’t dual-ship CJS/ESM, but rather than you should go TS (A) -> JS (A) -> JS(A+ B) where A is either CJS or ESM, B is the other one, and the tool doing the second arrow is some tool that isn’t TypeScript. If there are things preventing that, those are the things that we want to hear about, not that half-baked mostly-non-working solutions derived from trying to make ill-advised TS (A) -> JS (B) transforms work when really, allowing that transformation in the first place was a really bad idea that we shouldn’t have implemented in the first place.

Sure, but this bug is about a situation where TypeScript is exactly mimicking the runtime.

Yes, I actually agree. This bug corresponding to the node16 | cjs case in https://github.com/cyberuni/typescript-module-resolutions-demo/blob/main/tests/node16/test-result.4.9.4.md which are working as expected.

Please log a specific bug about which line of emit or resolution is wrong.

Will do as I’m going through the cases in https://github.com/cyberuni/typescript-module-resolutions-demo It will take me some time to get to them. But basically the cases marked with ❌ are potentially bugs. For example in: https://github.com/cyberuni/typescript-module-resolutions-demo/blob/main/tests/node-es/test-result.4.9.4.md https://github.com/cyberuni/typescript-module-resolutions-demo/blob/main/tests/node16/test-result.4.9.4.md

Correct; our position is that you should pick one runtime target

That is one of the main problems. There are cases that we want to support both CJS and ESM, especially in corporate environment. I’m sure you know how slow teams can move in those environments. As a library author, we can’t just dump CJS and produce ESM only packages, while we add features to the packages. At the same time, we need to expose ESM code so that we can leverage the benefits of it in newer projects.

Producing a single artifact for both CJS and ESM is really only possible when all of your dependencies adhere to a set of coincidences

That is not the case, and is probably one of the reasons why there are some communication breakdown. Even without any “dependencies”, what we are saying is that TypeScript as it stands simply unable to produce Dual module packages that works for both ESM and CJS consumers.

The intended behavior of the Node16 module resolution mode is to follow Node’s module resolution logic, and in this case, TypeScript correctly does. This is a correct error. react-use-websocket’s type definition, and the runtime definition of its module, do not provide a function at the requested place.

If you load react-use-websocket from a CommonJS module, you can see its public shape (running this code in a .js file from a non-type: "module" repo):

// test.js
console.log(Object.keys(require("react-use-websocket")));

> node test.js
[
  'default',
  'useSocketIO',
  'ReadyState',
  'useEventSource',
  'resetGlobalState'
]

Its .default property is the function, as “expected”.

If you load react-use-websocket from an ES module, you can see its public shape as well:

// test.mjs
import * as ws from "react-use-websocket";
console.log(Object.keys(ws));
console.log(Object.keys(ws.default));
console.log(Object.keys(ws.default.default));

> node test.mjs
// Object.keys(ws)
[
  'ReadyState',
  '__esModule',
  'default',
  'resetGlobalState',
  'useEventSource',
  'useSocketIO'
]
// Object.keys(ws.default)
[
  'default',
  'useSocketIO',
  'ReadyState',
  'useEventSource',
  'resetGlobalState'
]
// Object.keys(ws.default.default)
[]

and the .default property isn’t a function

import * as ws from "react-use-websocket";
// 'false'
console.log(ws.default instanceof Function);

The CJS<->ESM interop story, per Node and TypeScript (but not necessarily other bundlers, YMMV) is that a CJS module gets wrapped in the default export of the corresponding synthesized ESM entry point. Node and TypeScript agree here that the esModule flag doesn’t do anything.

So node and TypeScript 100% agree on this count: If you import this module from an ESM context, the object you get back (from the * import) has a default property with a default property. That’s the correct way to refer to that function.

Really, react-use-websocket shouldn’t be providing an export named "default" in a CJS module, because it’s never (in a cromulent module configuration) going to be consumable from a default import form.