ยท hands on

Write a simple TypeScript script with ESM

I recently wrote a small TypeScript script to generate a Markdown file with a sluggified filename. Since we're now in the era of modern ECMAScript Modules (ESM), I wanted to use this new module system in my TypeScript code. Here's how I did it.

I installed ts-node so I could run my script in a Node.js environment from the command line. The great thing about ts-node is that it works well with ESM. It comes with a binary called ts-node-esm that supports loading ECMAScript modules when you run TypeScript code.

Contents

Making TypeScript Work with ESM

Once you've installed ts-node with npm install, you can immediately direct it to your script by:

npx ts-node-esm src/main.ts

Please note that we are using ts-node-esm instead of ts-node to leverage its ESM loading capabilities.

This requires marking your Node.js package as an ECMAScript module. You can achieve this by using specific file extensions (as discussed later in the article) or by setting "type": "module" in your package.json file.

Additionally, ensure your tsconfig.json file has the module option set to Node16 (for module resolution introduced in Node v16) or NodeNext (for experimental module resolution).

Handling File and Folder Paths

In the world of ESM, the usual __filename and __dirname constants are not available. So, I found a way to mimic this by using the import.meta property:

import path from 'node:path';
import url from 'node:url';
 
const __filename = url.fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

Using New File Extensions

TypeScript lets you pick different file extensions to tell whether you're using ES modules (.mts) or CommonJS modules (.cts).

If you only need to run a single script with the ES module syntax, I recommend using the .mts extension. It's great in scenarios where you don't want to set up your whole project to use ES modules.

Demo Code

Here's the complete script I developed:

src/new-post.mts
import GithubSlugger from 'github-slugger';
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
 
const __filename = url.fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
 
const args = process.argv.slice(2);
const title = args[0];
const slugger = new GithubSlugger();
const slug = slugger.slug(title);
const filePath = path.join(__dirname, 'content', 'blog', `${slug}.md`);
 
console.log(`Creating file "${filePath}"...`);
fs.writeFileSync(filePath, '', 'utf8');
console.log(`Created file "${filePath}".`);

It can be executed as follows:

npx ts-node-esm src/new-post.mts "My Post Title"

Fixing ERR_UNKNOWN_FILE_EXTENSION

There is the possibility that this error occurs when using Node.js v20:

TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for src/start.mts

This is because ts-node v10.9.2 doesn't work well with Node.js v20 and ECMAScript modules (see GitHub issue).

Luckily, there is a solution. You just have to make use of the --loader flag of Node.js:

node --loader ts-node/esm src/new-post.mts "My Post Title"

Unfortunately, this workaround doesn't provide type checking as it is broken with ts-node v10.9.2, so your error output will appear as follows:

node:internal/process/esm_loader:40
      internalBinding('errors').triggerUncaughtException(
                                ^
[Object: null prototype] {
  [Symbol(nodejs.util.inspect.custom)]: [Function: [nodejs.util.inspect.custom]]
}

To address this shortcoming, we can use TypeScript's compiler for type checking. To use TypeScript's compiler solely for type checking, enable the noEmit flag to prevent JavaScript output. The JavaScript output will be generated by ts-node:

tsc --noEmit && node --loader ts-node/esm src/new-post.mts "My Post Title"

You may see an experimental warning because the --loader flag is likely to be removed in future Node.js versions. To hide this message, use the following command:

tsc --noEmit && node --no-warnings=ExperimentalWarning --loader ts-node/esm src/new-post.mts "My Post Title"

We can fine-tune our workflow by disabling type checking in ts-node, as it's already handled by tsc. Normally, we would do this as follows:

ts-node-esm --transpileOnly src/new-post.mts "My Post Title"

Unfortunately, we cannot simply pass the --transpileOnly flag to node. When doing so, we will encounter this error:

node: bad option: --transpileOnly

In such cases, we can utilize the environment variables provided for each CLI option:

TS_NODE_TRANSPILE_ONLY=true tsc --noEmit && node --no-warnings=ExperimentalWarning --loader ts-node/esm src/new-post.mts "My Post Title"

Video Tutorial

For a full explanation, check out the video I made here:

Back to Blog