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.