Using zx with TypeScript, ESM and top-level await
April 21, 2023
zx with TypeScript, ESM and top-level awaitApril 21, 2023
zx is a cool library by Google to
write shell-like scripts in Node.js.
As shown in their main example, you could have a file myscript.mjs:
#!/usr/bin/env zx
await $`cat package.json | grep name`
And run it as ./myscript.mjs, or even put it in your PATH and run it
as myscript.mjs.
This works if you installed zx globally. If you want to keep it local,
#!/usr/bin/env npx zx should work with most env implementations.
They say that you have to use a .mjs extension, and if you prefer
.js or no extension at all, you won’t have access to top-level
await, and you need to wrap your code in an
IIFE:
#!/usr/bin/env zx
void async function () {
await $`cat package.json | grep name`
}()
This actually doesn’t appear to be necessary! It looks like when
invoking the script via zx, it forces it to be interpreted as an
ECMAScript module, even without extension, so the original example will
work regardless of how the script is named. Sweet.
What if you want TypeScript though? They just document that you need to
explicitly import zx and use an IIFE again:
import { $ } from 'zx'
// Or
import 'zx/globals'
void async function () {
await $`ls -la`
}()
But they also tell you that you need to set "type": "module" in your
package.json and "module": "esnext" in tsconfig.json. There’s no
mention what shebang to use, nor what file extension.
It turns out you don’t necessarily need to do all this. Let’s dig in the details.
It’s clear that zx doesn’t support TypeScript out of the box, so we
can ditch the #!/usr/bin/env -S npx zx shebang. We need something that
will parse TypeScript, and because we can’t rely on the zx wrapper,
we’ll need to import zx explicitly. No problem.
Let’s go with ts-node first,
because it’s one of the most common options to do this.
TypeScript defaults to transpiling to CommonJS modules, so we won’t be
able to use top-level await out of the box. We also won’t be able to
use an import statement (that TypeScript translates to require) to
import zx, because zx is an ESM-only package. But we can use dynamic
import for that:
#!/usr/bin/env -S npx ts-node
void async function () {
const { $ } = await import('zx')
await $`echo ok`
}()
You’ll also need to set "moduleResolution": "nodenext" in the
compilerOptions of your tsconfig.json for it to support dynamic
imports like this, but you have to be careful, because this will change
the settings for your whole app!
Alternatively, you could put your script in a subdirectory, and have a
dedicated tsconfig.json there, then you could set those settings
locally to this subdirectory without affecting the rest of your app.
The advantage of this method is that the extension doesn’t matter!
You can have this script in myscript.ts but you can as well have it
just myscript for being more command-looking. This a pretty good
advantage of this solution as we’ll see later.
Note: keep in mind because this will run in whatever directory the
script was run from, npx will try to install ts-node globally if
you’re not running this from a directory where ts-node is part of the
local modules.
Most of the time this is fine, but if you want a script that can be called from anywhere, you would be better off using a wrapper shell script, like we’ll see below.
Alternatively, we can pass --compilerOptions to ts-node directly in
the shebang to avoid depending on a tsconfig.json. The problem is
that there’s no cross-platform way to this (this is made harder by the
fact we have to pass a JSON string).
On macOS:
#!/usr/bin/env npx ts-node --compilerOptions {"moduleResolution":"nodenext"}
On Linux:
#!/usr/bin/env -S npx ts-node --compilerOptions '{"moduleResolution":"nodenext"}'
Notice how on macOS, the quotes of the JSON object were not escaped!
Its implementation of env doesn’t try to parse quotes in the first
place, so we can (and need) to give them as is. This also means we can’t
include spaces as the JSON would be split into multiple arguments.
For Linux, we have to pass -S (makes env split arguments), but then
it does support quoting and various escape sequences, so we have to
add the quotes. macOS “supports” the -S option but currently it just
ignores it and treats the rest of the string as it normally does.
Sadly I’m not aware of a way to do this in a cross-platform way, without having to resort to a wrapper shell script. If you have a better option, let me know!
Such a script would look like:
#!/bin/sh
cd "$(dirname "$0")"
npx ts-node --compilerOptions '{"moduleResolution":"nodenext"}' myscript.ts
It would be in a myscript file next to myscript.ts, and you invoke
it with ./myscript.
This will not preserve the CWD
information, because it cd into the script directory first. The
advantage is that now npx will find your local version of ts-node
regardless where you run the script from.
At that point you could even bypass npx, e.g. if your script is in a
bin directory at the root of your project, you could run
../node_modules/.bin/ts-node instead of npx ts-node and remove the
extra latency from npx.
For the rest of this post I’ll user the Linux version of the shebang for simplicity. Adapt accordingly to your needs, either for macOS or using a script wrapper for portability.
If you want to import other parts of your codebase, you should probably stick with the previous approach. I’ll continue by exploring options to make this particular script ESM, but keep in mind that if you import non-ESM parts of your application, this will confuse TypeScript (especially with default exports) and you’ll likely run into issues.
If you don’t though, we have a few ways to force it to be ESM, so we can
directly import zx and also use top-level await!
ts-node has a --esm option to parse the input as ECMAScript module,
and even ships a ts-node-esm
executable to do the same thing.
On top of that, we need to configure the TypeScript compiler to support
ESM, which we do by adding the following to our tsconfig.json:
{
"compilerOptions": {
"moduleResolution": "nodenext",
"module": "esnext",
"target": "esnext"
}
}
As we saw earlier, we can pass that to ts-node in a
--compilerOptions flag. This gives us:
#!/usr/bin/env -S npx ts-node --esm --compilerOptions '{"moduleResolution":"nodenext","module":"esnext","target":"esnext"}'
import { $ } from 'zx'
await $`echo ok`
We can now import zx directly and happily use to-level await, and
get rid of that IIFE!
One thing we notice right away though is that the ts-node ESM loader
doesn’t let us use any extension (or in particular, no extension). It
needs to be in a .mts file. This means no more command-looking
script. It seems to be related to this issue
on the Node.js side.
ts-node doesn’t have a reputation to be fast, actually quite the
opposite. Its excuse is that it not only transpiles TypeScript to
JavaScript, but also performs type checking.
ts-node --transpile-onlyWe can pass --transpile-only to skip the type checking part, which
does improve the performance quite a bit:
#!/usr/bin/env -S npx ts-node --esm --transpile-only --compilerOptions '{"moduleResolution":"nodenext","module":"esnext","target":"esnext"}'
import { $ } from 'zx'
await $`echo ok`
This small example takes 500 ms to run on my machine, as opposed to 1 second when it was doing type checking!
time ./myscript.mts
1.61s user 0.13s system 152% cpu 1.137 total
time ./myscript-transpile-only.mts
0.48s user 0.09s system 96% cpu 0.587 total
It’s still relatively slow though, considering esbuild takes 15 ms to transpile that file:
$ time node_modules/.bin/esbuild myscript.mts --target=node16
0.01s user 0.01s system 79% cpu 0.014 total
But for a fair comparison, we have to consider that npx adds a 200 ms
overhead:
$ time npx esbuild myscript.mts --target=node16
0.21s user 0.04s system 111% cpu 0.227 total
tsxInterestingly, there’s a cool project called tsx,
which is TypeScript’s analogue to npx. And it uses esbuild in the
background.
#!/usr/bin/env -S npx tsx
import { $ } from 'zx'
await $`echo ok`
But we notice it’s not quite fast, it does barely better than ts-node --transpile-only:
$ time ./myscript.mts
0.42s user 0.08s system 120% cpu 0.413 total
There’s an issue open for that,
and it seems that it’s because tsx target older Node.js versions in a
way where it transpiles all the imported node_modules too! And it
seems that there’s currently no way around this behavior.
And again, this relies on a .mts extension being present for ESM
support. And even if you go the CommonJS route, you’ll still need a
.ts extension, unlike when using ts-node. It won’t work with
extensionless scripts.
SWC is a “Rust-based platform for the web”, but really the part I care about is that it claims to transpile TypeScript to JavaScript pretty damn fast, just like esbuild.
They provide swc-node to
run TypeScript files with Node.js, which is exactly what we want. It’s
not directly a command we can invoke unlike ts-node, instead we need
to do:
node --require @swc-node/register script.ts # CJS
node --loader @swc-node/register/esm script.ts # ESM
So we can and that to our shebang!
#!/usr/bin/env -S node --loader @swc-node/register/esm
import { $ } from 'zx'
await $`echo ok`
This is the fastest one so far!
$ time ./myscript.mts
0.28s user 0.04s system 110% cpu 0.289 total
However, we have to consider that it uses node --loader
instead of npx like the previous examples, and as we saw npx costs
200 ms by itself.
Also, even with today’s latest Node.js, custom ESM loaders are experimental, so running the script like this will show the following warning:
(node:13239) ExperimentalWarning: Custom ESM Loaders is an experimental feature and might change at any time
Also you need to make sure your tsconfig.json contains "target": "esnext"
in compilerOptions otherwise SWC will not let you use top-level
await. Unlike the previous options, we can’t customize this directly
in the shebang.
Lastly, we also need a .mts extension for this to work, like with all
the ESM solutions so far.
Note: I couldn’t get swc-node to work with a CJS file, with the
IIFE and dynamic import. Even with a .swcrc, which requires running
your code as SWCRC=true ./myscript.ts, it keeps transpiling the
dynamic import into a require statement, which is not supported by
zx.
ts-nodeA cool surprised I found while writing this post is that ts-node
actually have first-class support for SWC!
All you need is install @swc/core or @swc/wasm, and then simply use
ts-node --swc, or set the following in your tsconfig.json:
{
"ts-node": {
"swc": true
}
}
In our initial example, this gives us:
#!/usr/bin/env -S npx ts-node --swc --compilerOptions '{"moduleResolution":"nodenext","module":"esnext"}'
void async function () {
const { $ } = await import('zx')
await $`echo ok`
}()
Note: we had to add "module": "esnext" too, probably because
ts-node and SWC have different defaults when it comes to this setting.
And for the ESM version:
#!/usr/bin/env -S npx ts-node --swc --esm --compilerOptions '{"moduleResolution":"nodenext","module":"esnext","target":"esnext"}'
import { $ } from 'zx'
await $`echo ok`
All things considered, my favorite option is the ts-node approach we
started from but with a few tweaks that we learnt about along the way:
plain ts-node in the default CommonJS environment, using an IIFE and
dynamic import, but with the addition of --compilerOptions and
--swc (or alternatively, --transpile-only) in the shebang:
#!/usr/bin/env -S npx ts-node --swc --compilerOptions '{"moduleResolution":"nodenext","module":"esnext"}'
void async function () {
const { $ } = await import('zx')
await $`echo ok`
}()
tsconfig.json for our executable scripts.The downside is that it’s not cross-platform, but we saw we can use a wrapper script to work around that if needed.
And if I don’t need need to import anything local to my CommonJS
project (or if I’m in a ESM project), I add --esm and "target": "esnext"
to benefit from top-level await:
#!/usr/bin/env -S npx ts-node --swc --esm --compilerOptions '{"moduleResolution":"nodenext","module":"esnext","target":"esnext"}'
import { $ } from 'zx'
await $`echo ok`
Sweet!