Updated 2026: switched the publish flow from yarn + npm tokens to pnpm + npm trusted publishing (see Footnote 2).
In the past, I have written about making NPM packages with just the typescript compiler (https://cmdcolin.github.io/posts/2022-05-27-youmaynotneedabundler). That post focused on the “CJS” build because it was arguably simpler. This one covers the “ESM” case.
TL;DR, minimal github repo for this post: https://github.com/cmdcolin/minimalpackage
Why pure-ESM now?
I like a ‘bundler-less’ approach to library distribution, which means multiple
files end up in dist referencing each other. Pure ESM requires those imports
to include the file extension, which used to be awkward, but now we have some
new options.
New tsconfig settings
Two new tsc settings,
allowImportingTsExtensions
and
rewriteRelativeImportExtensions,
let us import .ts files in src and have tsc rewrite them to .js in dist.
For example:
// src/index.ts
import { bar } from './bar.ts'
export function foo () {
bar ()
}
// src/bar.ts
export function bar () {
console . log ( 'bar in bar.ts' )
}
Running tsc produces:
❯❯❯ 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
dist/index.js now has the rewritten .js import:
import { bar } from './bar.js'
export function foo () {
bar ()
}
and dist/bar.js:
export function bar () {
console . log ( 'bar in bar.ts' )
}
Previously you had to write import { bar } from './bar.js' in src to get
this behavior. Now you can reference the actual bar.ts file.
The package.json
{
"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" : "pnpm clean" ,
"preversion" : "pnpm build" ,
"postversion" : "git push --follow-tags"
},
"devDependencies" : {
"rimraf" : "^6.0.1" ,
"typescript" : "^5.7.3"
}
}
Notes:
-
I specify both
mainandexports.exportsalone works for consumers onmoduleResolution: nodenext, which is still uncommon. -
No
typesfield is needed. tsc picks it up from the.d.tsnext to the entry. -
exportsis often a complex object, but a plain string pointing at a file is enough here. -
rimrafclearsdistbefore each build.pnpm build --watchpasses--watchthrough to tsc. -
pnpm version <patch|minor|major>triggerspreversion/postversion, which cleans, builds, and pushes the new tag to github. Combined with npm trusted publishing, a GitHub Actions workflow then publishes to NPM without long-lived tokens. See Footnote 2.
The tsconfig.json
{
"include" : ["src" ],
"compilerOptions" : {
"outDir" : "dist" ,
"target" : "es2022" ,
"module" : "nodenext" ,
"moduleResolution" : "nodenext" ,
"declaration" : true ,
"strict" : true ,
"allowImportingTsExtensions" : true ,
"rewriteRelativeImportExtensions" : true
}
}
Using minimalpackage
import { foo } from 'minimalpackage'
foo ()
Footnote 1 - Publishing dual ESM/CJS using this technique
If you need dual ESM/CJS, here is a sketch of how to do it with this method: https://gist.github.com/cmdcolin/c3089a4b37f2ff8c8eabce5ebd3b4082
Footnote 2 - Setting up npm trusted publishing
Trusted publishing lets a GitHub Actions workflow publish to NPM via OIDC, so
you don’t have to manage an NPM_TOKEN secret.
Configure the trusted publisher for your package, pointing at your GitHub repo
and the workflow filename (e.g. publish.yml). This can be done via the
npmjs.com web UI under Settings -> Publishing access, or via the npm CLI on
v11+.
Then add .github/workflows/publish.yml, triggered on tag pushes (which is what
pnpm version produces via postversion):
name : Publish
on :
push :
tags :
- 'v*'
jobs :
publish :
runs-on : ubuntu-latest
permissions :
id-token : write
contents : read
steps :
- uses : actions/checkout@v4
- uses : pnpm/action-setup@v4
with :
version : 10
- uses : actions/setup-node@v4
with :
node-version : 24.x
cache : pnpm
registry-url : 'https://registry.npmjs.org'
- run : pnpm install --frozen-lockfile
- run : pnpm build
- run : npm publish --access public
A few load-bearing details:
id-token: writeletsnpm publishexchange a GitHub OIDC token for an npm publish credential, so noNPM_TOKENis needed.registry-urlinsetup-nodeis what writes the.npmrcused by the OIDC exchange. Don’t drop it.- We use
npm publishinstead ofpnpm publishbecause the OIDC integration lives in the npm CLI. pnpm install --frozen-lockfileassumes apnpm-lock.yamlis committed.
Full flow: pnpm version patch locally, postversion pushes the tag, and
GitHub Actions sees the v* tag and publishes.
Footnote 3 - Add the eslint-plugin-import with rule requiring file extension imports
Add this to your eslint config:
'import/extensions' : [ 'error' , 'ignorePackages' ],
It will enforce importing from specific files, except for package names, e.g.
you need to import from ‘yourfile.ts’ for your code, but can import from
packages like normal: import { useState } from 'react'.