evolved.io logotype

#TypeScript #Node.js #ts-node #ECMAScript-Modules

ECMAScript Modules With Typescript

A brief introduction to the use of ECMAScript modules and module resolutions in a TypeScript setup.

Avatar
Dennis GaidelJune 13, 2022

After writing a few bash scripts for various stages in GitLab CI/CD pipelines, I stumbled upon a great Node.js library from Google, called zx (link).

It wraps Node.js's child_process modules, which deals with subprocesses and gives access to the stdout (link).

The only catch was that the library is only exported as an ECMAScript module. At the same time the intention was to use zx in a TypeScript/Node.js project, that generates commonjs modules that don't go along with modules, like zx.

ECMAScript Modules

You can read more about the nature of ECMAScript modules in a great blog article on Formidable.com (link) and one provided by Mozilla (link):

ES modules bring an official, standardized module system to JavaScript. It took a while to get here, though nearly 10 years of standardization work.

Parts of the existing project was also dependent on ts-node (link) for executing TypeScript files "on the fly" and ts-node-dev (link) for "live TypeScript coding" with automatic, efficient restarts.

As soon as I started making the changes and reading the ts-node docs, I noticed the following statement (link):

There's also ts-node-dev, a modified version of node-dev using ts-node for compilation that will restart the process on file change. Note that ts-node-dev is incompatible with our native ESM loader.

Minimal Setup

Okay, fun. Before hitting any more road blocks by turning too many screws at the same time, I decided to spin up a new TypeScript project and focus on the minimal set of changes necessary to get things going.

npm init --yes
npm install -D typescript
npm install -D ts-node
npm install -D @types/node

That's a great start. What's left to do, is to work on a few configurations. So let's create a TypeScript configuration file first:

touch tsconfig.json

There is a fantastic GitHub project tsconfig/bases that provides some sane defaults, that can be used as a foundation:

npm install --save-dev @tsconfig/node16-strictest

TypeScript and Node Configuration

Inside our tsconfig.json, we can now make the following changes:

{
    "extends": "@tsconfig/node16-strictest/tsconfig.json",
    "compilerOptions": {
        "module": "ESNext",
        "moduleResolution": "node",
        "outDir": "dist"
    }
}

Initially the aforementioned default configuration is extended. Next, module specifies what module code is generated. ESNext refers to the common import/export expressions instead of transforming those into require() ones.

moduleResolution describes how TypeScript looks up a file from a given module specifier. It basically tells TypeScript how to find this module, referenced in a line like import { ... } from 'src/func'.

As it defers to node for the module resolution, we need to add one additional line in our package.json file:

{
  ...
  "type": "module",
  ...
}

This property value tells Node to use its ECMAScript modules loader to load the referenced modules (link).

First Build

There is nothing more to do to transpile TypeScript files and run the generated JavaScript files with node. You could create a new file in src/index.ts and add a function in there:

void (async function () {
    console.log('Et voila!')
})()

Running tsc would lead to the generation of a build folder (in this case dist) that contains the generated files.

Executing TypeScript Files

Sometimes it's not necessary to transpile TypeScript into JavaScript. It is sufficient to execute the files directly, for example to execute them within a CI/CD pipeline. In this case it's handy to set up ts-node as well, which is very well explained in their docs (link).

The following lines need to be added to tsconfig.json:

{
  ...
  "ts-node": {
    "esm": true
  }
}

Executing a TypeScript file is as easy as executing the following file:

ts-node src/index.ts

There is one (huge) downside mentioned in the docs considering the support for ESM in production:

Node's ESM loader hooks are experimental and subject to change. ts-node's ESM support is as stable as possible, but it relies on APIs which node can and will break in new versions of node. Thus it is not recommended for production.

For now, I assume that as long as you hold on to your versions and are aware that upgrades to newer ts-node and node.js version might be a bit slower due to the lack of backward compatibility of those instable APIs.

Speed Things Up with SWC

There are few more options you could set in your tsconfig.json in order to speed things up with ts-node (link):

{
    "ts-node": {
        "esm": true,
        "files": true,
        "transpileOnly": true,
        "swc": true
    }
}

The biggest performance gain comes from using the super fast rust based transpiler called swc and skipping type checks.

Module Resolutions

Remember the module resolutions mentioned earlier? There is another catch, when you create a second TypeScript file, such as moduleA.ts and try to import it in index.ts:

import { func2 } from './moduleA'

void (async function () {
    console.log(func2())
})()

This leads to the following error message complaining that it can't find moduleA:

throw new ERR_MODULE_NOT_FOUND(...)

CustomError: Cannot find module '.../src/moduleA' imported from ...src/index.ts

After some more digging and confusion, why it would not automatically find moduleA.ts when referencing it as ./moduleA, I found the answer in the Node.js docs as well (link):

A file extension must be provided when using the import keyword to resolve relative or absolute specifiers. Directory indexes (e.g. './startup/index.js') must also be fully specified.

This behavior matches how import behaves in browser environments, assuming a typically configured server.

That means it's absolutely necessary to add the .ts extension unless the experimental usage of automatic file extension resolutions is used (link):

In tsconfig.json the following file needs to be added:

{
  ...
  "tsconfig": {
    ...
    "experimentalSpecifierResolution": "node",
    ...
  }
}

And once the files are transpiled, the files can only be executed using node with a flag:

node --experimental-specifier-resolution=node dist/index.js

The Node.js documentation warns against relying on the usage of this flag, so it's far from perfect:

Do not rely on this flag. We plan to remove it once the Loaders API has advanced to the point that equivalent functionality can be achieved via custom loaders.

The current specifier resolution does not support all default behavior of the CommonJS loader. One of the behavior differences is automatic resolution of file extensions and the ability to import directories that have an index file.

Conclusion

There still exists a high dynamic in the use and implementation of ECMAScript modules. This is true for both Node.js and TypeScript executors like ts-node.

Accordingly, an increased maintenance effort is to be expected in a production system when upgrading versions.