Misc scribbles

Making a pure-ESM package using `tsc`

2025-01-12

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

#Why pure-ESM now?

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

#We have some new tsconfig.json settings to help

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

#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": "yarn clean",
    "preversion": "yarn build",
    "postversion": "git push --follow-tags"
  },
  "devDependencies": {
    "rimraf": "^6.0.1",
    "typescript": "^5.7.3"
  }
}

Some things you can observe

#The tsconfig.json

{
  "include": ["src"],
  "compilerOptions": {
    "outDir": "dist",
    "target": "es2020",
    "declaration": true,
    "strict": true,
    "allowImportingTsExtensions": true,
    "rewriteRelativeImportExtensions": true
  }
}

#Using minimalpackage

import { foo } from 'minimalpackage'
foo()

#Conclusion

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.

#Footnote 1 - Publishing dual ESM/CJS using this technique

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