In the past, I have written about making NPM packages with just the typescript compiler (https://cmdcolin.github.io/posts/2022-05-27-youmaynotneedabundler). There, I focused on "CJS" build because it was arguably simpler
Now, I am addressing the "ESM" case
TLDR: here is the minimal github repo for this post https://github.com/cmdcolin/minimalpackage
As my previous article mentions, I like taking a 'bundler-less' approach to
library distribution. That means that multiple files might end up in the dist
folder which reference each other. However, using "pure ESM" requires these
files that reference each other to import the actual path, with the file
extensions
Which was awkward before...but now
The recent addition of the tsc
settings
allowImportingTsExtensions
and
rewriteRelativeImportExtensions
have now allow us to import from the .ts file extension in the src folder, and
it automatically rewrites to use the .js file extension in the dist folder
For example we can have
// src/index.ts
import { bar } from './bar.ts'
export function foo() {
bar()
}
// src/bar.ts
export function bar() {
console.log('bar in bar.ts')
}
then running tsc over these files will produce
❯❯❯ ll dist
total 24K
-rw-rw-r-- 1 cdiesh cdiesh 37 Jan 14 06:07 bar.d.ts
-rw-rw-r-- 1 cdiesh cdiesh 91 Jan 14 06:07 bar.js
-rw-rw-r-- 1 cdiesh cdiesh 43 Jan 14 06:07 index.d.ts
-rw-rw-r-- 1 cdiesh cdiesh 116 Jan 14 06:07 index.js
where dist/index.js now contains an import with the .js extension
import { foo } from './bar.js'
foo()
and dist/bar.js says
import { bar } from './bar.js'
export function foo() {
bar()
}
Previously you had to write import {foo} from './bar.js'
in the src folder to
have this behavior, but now you can reference the actual file, bar.ts
{
"name": "minimalpackage",
"version": "1.0.0",
"description": "simple pure-esm package compatible with tsc",
"license": "MIT",
"type": "module",
"exports": "./dist/index.js",
"main": "./dist/index.js",
"scripts": {
"build": "tsc",
"clean": "rimraf dist",
"prebuild": "yarn clean",
"preversion": "yarn build",
"postversion": "git push --follow-tags"
},
"devDependencies": {
"rimraf": "^6.0.1",
"typescript": "^5.7.3"
}
}
Some things you can observe
I specify main and exports. Exports is sufficient for some but requires consumers use moduleResolution with nodenext which is rare
You can see from the above, I did not need to specify the location of "types"
Lots of usages of "exports" have a complex object but it can simply be a string that points to a file
I use rimraf to clear the dist folder before building. I can use "yarn build --watch" for a tsc watcher
You can run yarn publish
to publish to NPM, and it will automatically run
the clean and build via the preversion script, and then will automatically
push the updated version and tag to github once it is finished via the
postversion script
{
"include": ["src"],
"compilerOptions": {
"outDir": "dist",
"target": "es2020",
"declaration": true,
"strict": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true
}
}
import { foo } from 'minimalpackage'
foo()
This article proposes a bundler-less approach to distributing typescript
packages on NPM. It was possible before, but I think the addition of the
allowImportingTsExtensions
and rewriteRelativeImportExtensions
made it more
sane.
I don't even know if I fully stand behind doing a dual publish this way, but for reference, here is a potential way to dual ESM/CJS publish with this method https://gist.github.com/cmdcolin/c3089a4b37f2ff8c8eabce5ebd3b4082
The 'quick start kit' tshy is also a pretty good minimal approach to publishing. You might consider trying it