I tried using ES Modules, I really did...

I tried using ES Modules, I really did...
Photo by Shahadat Rahman / Unsplash

I am an early adopter of technology, as long I don't have to pay and what better way to do that than using the latest and the greatest of the frameworks or tools while developing. This is my experience of trying to use ES Modules in my TypeScript project and my thoughts on it.

I am working on a library to scaffold discord bots, (I maintain a couple of them and a scaffold would decrease a lot of the redundant work) and I use TypeScript for that. Now, I started my project using tsdx but I soon found out that it was not maintained nowadays. So, instead of using a fork, I decided to roll out my own Typescript starter to use it myself and for the community. Enter ts-boot which is still a WIP (contributions welcome).

Since I had a clean slate in front of me, I was thinking of using the latest standards and what could be more latest than using ES Modules instead of CommonJS. TypeScript with ES2020 targeting Node 14+ and ES Modules would be bleeding edge and a dream to develop right?

Wrong!

Now before the internet people starts judging me I should state that this is not my first rodeo in TypeScript. I have a love-hate relationship with TypeScript. I keep coming back to Typescript only to get frustrated again. I love typed languages yet the disjoint way that most libraries have their code and typings separated (via DefinitelyTyped) makes things more cumbersome that it needs to be. Quite a few times I have faced the issue wherein the @types package has not been updated for a package that has been. This causes friction that I could have avoided if I just stuck with JavaScript. But those sweet sweet types brought me back again and again.

Now don't get me wrong. I hardly face any issues nowadays. Many large packages provide their own types which makes things much smoother and for those that still have broken types, I can use any casting to force things to work. Now you might ask, why I don't contribute by fixing the types. I do. But working with the DefinitelyTyped monorepo with its bazillion files is not exactly a great experience. I can do a bare clone in git but still, its not exactly a great experience.

So, knowing all these pains, I still decided to not only use TypeScript but also use ES Modules in my latest project. This is what my package.json and tsconfig.json looks like:

{
  "name": "ts-boot",
  "version": "0.0.1",
  "description": "Typescript project bootstrapper",
  "keywords": [
    "typescript",
    "boot"
  ],
  "type": "module",
  "homepage": "https://github.com/SayakMukhopadhyay/ts-boot#readme",
  "bugs": {
    "url": "https://github.com/SayakMukhopadhyay/ts-boot/issues"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/SayakMukhopadhyay/ts-boot.git"
  },
  "license": "Apache-2.0",
  "author": "Sayak Mukhopadhyay <mukhopadhyaysayak@gmail.com>",
  "main": "index.js",
  "bin": {
    "tsboot": "./dist/index.js"
  },
  "files": [
    "dist",
    "templates"
  ],
  "scripts": {
    "start:create": "ts-node src/index.ts create myproject",
    "start:create:basic": "ts-node src/index.ts create myproject -t basic",
    "start:build": "ts-node src/index.ts build lmao",
    "build": "tsc",
    "build:watch": "tsc --watch",
    "prepare": "husky install",
    "lint": "eslint \"{src,templates,test}/**/*.ts\"",
    "lint:fix": "eslint \"{src,templates,test}/**/*.ts\" --fix",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage",
    "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
    "test:e2e": "jest --config ./test/jest-e2e.json"
  },
  "dependencies": {
    "commander": "^8.2.0",
    "fs-extra": "^10.0.0",
    "inquirer": "^8.1.5",
    "mustache": "^4.2.0",
    "typescript": "^4.4.3"
  },
  "engines": {
    "node": ">=14"
  },
  "devDependencies": {
    "@types/fs-extra": "^9.0.13",
    "@types/inquirer": "^8.1.3",
    "@types/jest": "^27.0.2",
    "@types/mustache": "^4.1.2",
    "@typescript-eslint/eslint-plugin": "^4.32.0",
    "@typescript-eslint/parser": "^4.32.0",
    "eslint": "^7.32.0",
    "eslint-config-prettier": "^8.3.0",
    "eslint-plugin-prettier": "^4.0.0",
    "husky": "^7.0.2",
    "jest": "^27.2.4",
    "prettier": "^2.4.1",
    "ts-jest": "^27.0.5",
    "ts-node": "^10.2.1"
  }
}

package.json

{
  "include": ["src/**/*"],
  "compilerOptions": {
    // type checking
    "exactOptionalPropertyTypes": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "strict": true,
    // module
    "module": "ES2020",
    "moduleResolution": "node",
    "rootDir": "src",
    "resolveJsonModule": true,
    // emit
    "outDir": "dist",
    // imterop constraints
    "allowSyntheticDefaultImports": true,
    "forceConsistentCasingInFileNames": true,
    // language and environment
    "lib": ["ES2020"],
    "target": "ES2020",
    // completeness
    "skipLibCheck": true
  }
}

tsconfig.json

My problems started immediately. I was importing from fs-extra as such:

import { copySync, readdirSync, readFile, readFileSync, writeFile } from 'fs-extra';
import { render } from 'mustache';
...

src/commands.ts

This immediately started failing. The reason is that I need to write my imports exactly the way I want it to be in the compiled JavaScript files. So, the correct way to do it would be:

import mustache from 'mustache';
import fs from 'fs-extra';

const { copySync, readdirSync, readFile, readFileSync, writeFile } = fs;
const { render } = mustache;

src/commands.ts

Moreover, during importing another module, one needs to use the .js extension. For eg.

import { Commands } from './commands.js';

src/index.ts

This makes my skin crawl. JavaScript is already a meme and this doesn't help the JS/TS ecosystem in any way. Moreover, .eslintrc.js files no longer work cause ESLint still uses CommonJS to load and since we are using "type": "module" in our package.json, the whole project is now ES Module'd. And although ES Module projects can call CommonJS modules, the other way round is not possible. So, you either gotta rename the ESLint config to .eslintrc.cjs or change the format to JSON. Similar actions may need to be taken for other libraries that uses a JS config file.

It was OK, till now but the final nail hit in the coffin when I found out that Jest would not work. No matter I used a JSON or a CJS file, my tests wouldn't run at all and would give a module error. I was planning to use ts-jest and my configuration looked like:

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node'
};

jest.config.js

Now I don't know if I missed something or was messing something up on my part but I had already spent around 2 days working on scaffolding the library which would scaffold TypesScript project so that I could scaffold my project which scaffolds a Discord project. I threw my arms in the air, changed the config to use CommonJS and started writing this blog. Maybe I will have an easier time in a year or so.

Look forward to more posts from me regarding my experiences in working with my projects.

This blog post is not meant to be a tutorial on how to (or how not to) use ES Modules with TypeScript. Its meant to document my experiences and take it as such. But my situation might resonate with someone who have gone through a similar situation.