Hybrid ESM/CJS Node Packages using TypeScript
- Update 2023/2/17: I found a new simpler way detailed here
- Update 2022/9/5: There is a TS issue covering the topic herein.
I worked out a solution to creating hybrid CJS/ESM Node packages using TypeScript’s new (since 4.7) ESM support.
Understanding What Needs to be Emitted
I thought that "import"
under the exports
systems/field of package.json
would force the consumer to see those files pointed at as ESM. This was wrong. It seems that in modern ESM there are only two ways to achieve this:
- The package
"type"
is"module"
and the files pointed at are.js
- The files pointed at have
.mjs
extension.
The extension takes precedence over the type
field. There’s also the combination of type
module
with .mjs
which I haven’t tried but presumably just “doubly” works.
With this knowledge I realized that for a hybrid package it is insufficient to use .js
extension. That extension leads to being interpreted as whatever is in the aforementioned "type"
field. And that field is mutually exclusive between modes.
Instead I must decide which way I’m going with type
and then for the other mode apply its explicit extension. Further, I can choose to ignore type
and and use explicit extensions everywhere. To recap all of the following will work:
"type" dist/esm dist/cjs
-------------------------------
"module" *.js *.cjs [2]
"module" *.mjs *.cjs
"commonjs" *.mjs *.js
"commonjs" *.mjs *.cjs
<unset> *.mjs *.cjs [1]
- [1] I am currently using this variation because it is most explicit.
- [2] I will probably transition to this approach because it is future facing.
Figuring Out How to Emit It
This was more work than I expected. Once TS is configured with compiler options:
"module": "NodeNext",
"moduleResolution": "nodenext",
it then seems to emit ESM only in two cases:
- Package
"type"
is"module"
- Source file extension is
mts
Furthermore TS only emits .cjs
and .mjs
extensions if file names have respectively .cts
and mts
. And recall that we need to emit these file extensions for hybrid packages (for the opposite side of however “type” is set).
The following is the current solution, I do hope it can be simplified in the future:
-
Package
.type
is left unspecified (that means it defaults tocommonjs
) -
Source files are all written
.mts
-
tsconfig.json
has:"module": "NodeNext", "moduleResolution": "nodenext",
-
tsconfig.esm.json
has:{ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "dist/esm", "rootDir": "src", "sourceMap": true, "declaration": true, "declarationMap": true }, "include": ["src"], "exclude": ["**/*.spec.*"] }
-
tsconfig.cjs.json
has:{ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "dist/cjs", "module": "commonjs", "moduleResolution": "node", "rootDir": "src", "sourceMap": true, "declaration": true, "declarationMap": true }, "include": ["src"], "exclude": ["**/*.spec.*"] }
-
Package build runs
tsc
twice, once against each mode-specific tsconfig. However, having.mts
files makes it impossible fortsc
to emit CJS… -
Therefore we have a build script that, on CJS build, performs the following transforms before
tsc
, and then after, undoes them:-
Rename all source files from
.mts
to.cts
-
Rewrite all imports from
... from './abc.mjs
to... from './abc.cjs'
. (I haven’t mentioned the nuance of how TS uses import paths of what will be emitted which is funky but not having impact on this work since I would just the same have had to transform.mts
to.cjs
. If ESM had not required explicit file paths or TS supported found a way to omit it then that would have made this easier. )
-
That is the solution, and you can see it in use working (as far as I am aware) in the repo jasonkuhrt/floggy. Soon it will be ported over to jasonkuhrt/template-typescript-lib.
The amount of complexity here is not nice. I want in the future something better. That could be for example:
- Stop writing hybrid packages
- TS team at Microsoft delivers more flexible ways of working with
tsc
- Encapsulate the logic in a reusable CLI
I will do (1) once I feel it doesn’t burden users much to do so. I am far from sure that will be soon. Its a feeling I guess. For me I’m not close to it yet. I will raise an issue with the TS team about (2) to see if there’s any hope there. Finally I might consider (3) as I can see needing this build system in already half a dozen or so packages and more to come. I also think the community needs it, or at least, I am not aware of any other solution to this problem using the same constraints (e.g. only using tsc
).