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 typscript 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/minimal_pureesm_package/

#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 { foo } from './bar.ts'
foo()
// src/bar.ts
export function foo() {
  console.log('foo 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

export function foo() {
  console.log('foo in bar.ts')
}

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",
  "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"
  }
}

You can see from the above, I did not need to specify "exports" or "types".

Other random 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 thing from 'minimalpackage'
thing()

#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. Previously you had to use "import {thing} from './localFile.js'" even when you were writing .ts which was awkward

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

If you want to publish ESM/CJS you can update your package.json to use the "exports" field, and run the tsc compiler twice over the code, once using --module cjs

{
  "name": "dualpackage",
  "version": "1.0.0",
  "description": "demo of dual esm/cjs package",
  "type": "module",
  "types": "./cjs/index.d.ts",
  "exports": {
    "import": {
      "import": "./esm/index.js"
    },
    "require": {
      "require": "./cjs/index.js"
    }
  },
 
  "scripts": {
    "clean": "rimraf cjs",
    "prebuild": "yarn clean",
    "preversion": "yarn build",
    "postversion": "git push --follow-tags",
    "build:esm": "tsc --outDir esm",
    "build:cjs": "tsc --module commonjs --outDir cjs",
    "postbuild:cjs": "echo '{\"type\": \"commonjs\"}' > cjs/package.json",
    "build": "yarn build:esm && yarn build:cjs"
  },
  "devDependencies": {
    "rimraf": "^6.0.1",
    "typescript": "^5.7.3"
  }
}

Note that postbuild:cjs (which is automatically run after any invocation of build:cjs) outputs a "one line" extra package.json to the cjs folder that says type:cjs specifically in the cjs distribution (credit: https://evertpot.com/universal-cjs-esm-typescript-packages/).

You can alternatively name the all the files in the cjs folder with the .cjs file extension, which node will recognize as being commonjs and not ESM module files, if you do not want to use the one-line-package.json-with-type:cjs trick, but the tsc compiler does not currently have an option to output the .cjs file extension natively