ยท 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

This ensures that an ECMAScript loader is ready when your code is executed.

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 ran as follows:

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

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 fix this, run type checking directly through TypeScript's compiler using the noEmit flag to prevent unintentional output:

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"

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

Back to Blog