Improve README documentation for TypeScript usage
See original GitHub issueExpected Behavior
The ‘TypeScript’ section of the README file would provide accurate, complete, and up to date information of how to use zx
with TypeScript.
Actual Behavior
The current ‘how to use TypeScript’ documentation appears inadequate:
Crawling through the release history it seems like support for TypeScript via the zx
binary was dropped in v5.0.0:
This release drops build of CommonJS version and support for
.ts
extension byzx
bin.TypeScript is still supported, for example, via ts-node:
node --loader ts-node/esm script.ts
Also, a new Node version requirement is >= 16.0.0.
Looking at the README at this commit seemed to have better usage information for how to use this with ts-node
:
https://github.com/google/zx/blob/7977cb5adb9545082b8edb8bfe647dedfcb98b42/README.md?plain=1#L398-L421
Which seems to have been removed ~17 days ago in this commit: https://github.com/google/zx/commit/db0e65163d31e37cc6d71ae2e20e2ada4186efa6#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5L542-R458
Following the current README as written, I tried making a foo.mjs
test file:
#!/usr/bin/env zx
import 'zx/globals'
void async function () {
const foo: string = "foo";
await $`ls -la`
}()
Running this with ./foo.mjs
(zx
bin in the shebang) results in the following error, presumably because .mjs
doesn’t specify it as a TypeScript file:
SyntaxError: Missing initializer in const declaration
at ESMLoader.moduleStrategy (node:internal/modules/esm/translators:117:18)
at ESMLoader.moduleProvider (node:internal/modules/esm/loader:337:14)
at async link (node:internal/modules/esm/module_job:70:21)
I also tried renaming it to foo.ts
, which resulted in this error:
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for /Users/devalias/dev/0xdevalias/minimal-zx-ts/foo.ts
at new NodeError (node:internal/errors:372:5)
at Object.getFileProtocolModuleFormat [as file:] (node:internal/modules/esm/get_format:76:11)
at defaultGetFormat (node:internal/modules/esm/get_format:118:38)
at defaultLoad (node:internal/modules/esm/load:21:20)
at ESMLoader.load (node:internal/modules/esm/loader:407:26)
at ESMLoader.moduleProvider (node:internal/modules/esm/loader:326:22)
at new ModuleJob (node:internal/modules/esm/module_job:66:26)
at ESMLoader.#createModuleJob (node:internal/modules/esm/loader:345:17)
at ESMLoader.getModuleJob (node:internal/modules/esm/loader:304:34)
at async Promise.all (index 0) {
code: 'ERR_UNKNOWN_FILE_EXTENSION'
}
Looking at the TypeScript docs for ‘ECMAScript Modules in Node.js’ it talks about various other file extensions that can be used:
The type field in package.json is nice because it allows us to continue using the
.ts
and.js
file extensions which can be convenient; however, you will occasionally need to write a file that differs from what type specifies. You might also just prefer to always be explicit.Node.js supports two extensions to help with this:
.mjs
and.cjs
..mjs
files are always ES modules, and.cjs
files are always CommonJS modules, and there’s no way to override these.In turn, TypeScript supports two new source file extensions:
.mts
and.cts
. When TypeScript emits these to JavaScript files, it will emit them to .mjs
and.cjs
respectively.
Based on this, I figured that maybe the .mts
extension would work, as it should be the ‘TypeScript flavoured’ version of the recommended .mjs
; but renaming to foo.mts
and running it again resulted in this error:
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".mts" for /Users/devalias/dev/0xdevalias/minimal-zx-ts/foo.mts
at new NodeError (node:internal/errors:372:5)
at Object.getFileProtocolModuleFormat [as file:] (node:internal/modules/esm/get_format:76:11)
at defaultGetFormat (node:internal/modules/esm/get_format:118:38)
at defaultLoad (node:internal/modules/esm/load:21:20)
at ESMLoader.load (node:internal/modules/esm/loader:407:26)
at ESMLoader.moduleProvider (node:internal/modules/esm/loader:326:22)
at new ModuleJob (node:internal/modules/esm/module_job:66:26)
at ESMLoader.#createModuleJob (node:internal/modules/esm/loader:345:17)
at ESMLoader.getModuleJob (node:internal/modules/esm/loader:304:34)
at async Promise.all (index 0) {
code: 'ERR_UNKNOWN_FILE_EXTENSION'
}
Based on the old README example of using ts-node
I decided to try that method, and followed the latest instructions/details from:
- https://github.com/TypeStrong/ts-node#native-ecmascript-modules
- https://github.com/TypeStrong/ts-node/issues/1007#issue-598417180
- https://github.com/TypeStrong/ts-node/issues/1007#issuecomment-1139917958
- https://github.com/TypeStrong/ts-node/issues/1007#issuecomment-1163471306
I can seemingly run the script with ts-node-esm
directly as:
npm exec ts-node-esm -- ./foo.mts
And by modifying the shebang to call ts-node-esm
:
#!/usr/bin/env npx --package=ts-node -- ts-node-esm
import 'zx/globals'
void async function () {
const foo: string = "foo";
await $`ls -la`
}()
We can run the file with a .ts
extension (when "type": "module",
is set in package.json
).
If we try to use a .js
or .mjs
extension we get the same error as earlier (as we would expect):
SyntaxError: Missing initializer in const declaration
at ESMLoader.moduleStrategy (node:internal/modules/esm/translators:117:18)
at ESMLoader.moduleProvider (node:internal/modules/esm/loader:337:14)
at async link (node:internal/modules/esm/module_job:70:21)
If we use the .mts
extension however, we can remove "type": "module",
from package.json
and it will still work (as .mts
are files are always ES modules)
We can make this run even faster if we use ts-node
’s swc
suport:
- https://typestrong.org/ts-node/docs/transpilers/
-
ts-node
supports third-party transpilers as plugins. Transpilers such asswc
can transform TypeScript into JavaScript much faster than the TypeScript compiler. You will still benefit fromts-node
’s automatictsconfig.json
discovery, sourcemap support, and globalts-node
CLI.
-
- https://typestrong.org/ts-node/docs/swc
-
SWC support is built-in via the
--swc
flag or"swc": true
tsconfig
option. -
SWC is a TypeScript-compatible transpiler implemented in Rust. This makes it an order of magnitude faster than vanilla
transpileOnly
. -
To use it, first install
@swc/core
or@swc/wasm
. If usingimportHelpers
, also install@swc/helpers
. If target is less than"es2015"
and usingasync
/await
or generator functions, also installregenerator-runtime
.
-
- https://github.com/swc-project/swc
- https://swc.rs/
Based on this we could install the @swc/core
lib, and modify our script’s shebang to add the --swc
arg as follows:
npm i --save-dev @swc/core
#!/usr/bin/env npx --package=ts-node -- ts-node-esm --swc
import 'zx/globals'
void async function () {
const foo: string = "foo";
await $`ls -la`
}()
Super basic testing on my laptop with time
shows the following speed improvements when using --swc
with ts-node
:
// time ./foo.mts
// Without --swc
./foo.mts 3.45s user 0.55s system 126% cpu 3.151 total
./foo.mts 3.29s user 0.34s system 153% cpu 2.374 total
./foo.mts 3.16s user 0.34s system 156% cpu 2.243 total
// With --swc
./foo.mts 1.14s user 0.46s system 69% cpu 2.299 total
./foo.mts 1.14s user 0.27s system 103% cpu 1.358 total
./foo.mts 1.13s user 0.27s system 103% cpu 1.356 total
Contrasting this against a plain .mjs
file using the zx
bin directly, we can see that the .mjs
is still faster, but for the dev efficiency improvements of using TypeScript, I feel like the tradeoff isn’t too bad when using tas-node
with --swc
:
#!/usr/bin/env zx
import 'zx/globals'
const foo = "foo";
await $`ls -la`
console.log(foo);
./foo.mjs 0.46s user 0.25s system 72% cpu 0.977 total
./foo.mjs 0.45s user 0.11s system 118% cpu 0.481 total
./foo.mjs 0.44s user 0.11s system 119% cpu 0.461 total
Trying to remove the wrapping async function
results in the following error:
/Users/devalias/.npm/_npx/1bf7c3c15bf47d04/node_modules/ts-node/src/index.ts:843
return new TSError(diagnosticText, diagnosticCodes, diagnostics);
^
TSError: ⨯ Unable to compile TypeScript:
foo.mts:7:3 - error TS1378: Top-level 'await' expressions are only allowed when the 'module' option is set to 'es2022', 'esnext', 'system', 'node16', or 'nodenext', and the 'target' option is set to 'es2017' or higher.
7 await $`ls -la`
~~~~~
at createTSError (/Users/devalias/.npm/_npx/1bf7c3c15bf47d04/node_modules/ts-node/src/index.ts:843:12)
at reportTSError (/Users/devalias/.npm/_npx/1bf7c3c15bf47d04/node_modules/ts-node/src/index.ts:847:19)
at getOutput (/Users/devalias/.npm/_npx/1bf7c3c15bf47d04/node_modules/ts-node/src/index.ts:1057:36)
at Object.compile (/Users/devalias/.npm/_npx/1bf7c3c15bf47d04/node_modules/ts-node/src/index.ts:1411:41)
at transformSource (/Users/devalias/.npm/_npx/1bf7c3c15bf47d04/node_modules/ts-node/src/esm.ts:400:37)
at /Users/devalias/.npm/_npx/1bf7c3c15bf47d04/node_modules/ts-node/src/esm.ts:278:53
at async addShortCircuitFlag (/Users/devalias/.npm/_npx/1bf7c3c15bf47d04/node_modules/ts-node/src/esm.ts:409:15)
at async ESMLoader.load (node:internal/modules/esm/loader:407:20)
at async ESMLoader.moduleProvider (node:internal/modules/esm/loader:326:11)
at async link (node:internal/modules/esm/module_job:70:21) {
diagnosticCodes: [ 1378 ]
}
Which we can fix by setting our tsconfig.json
to something like this:
{
"compilerOptions": {
"target": "ES2021",
"module": "node16",
},
}
Which then allows us to simplify the basic code to:
#!/usr/bin/env npx --package=ts-node -- ts-node-esm
import 'zx/globals'
const foo: string = "foo";
await $`ls -la`
We could also use something like the following (from https://github.com/tsconfig/bases#node-16--esm--strictest-tsconfigjson) rather than handcrafting our own compatible tsconfig.json
:
npm i @tsconfig/node16-strictest-esm
// tsconfig.json
{
"extends": "@tsconfig/node16-strictest-esm/tsconfig.json"
}
An alternative to using ts-node
could be to use tsm
(which wraps esbuild
), but I didn’t look too deeply into how to make it work:
Steps to Reproduce the Problem
- Be a newbie to using
zx
- Wonder how to use it effectively with TypeScript
- Get confused
Specifications
- Version: 7.0.5
- Platform: node v16.15.1, macOS 12.3.1
Other older TypeScript related issues that I looked through first
- https://github.com/google/zx/pull/409
- https://github.com/google/zx/issues/273
- https://github.com/google/zx/issues/206
- https://github.com/google/zx/issues/205
- https://github.com/google/zx/issues/197
- https://github.com/google/zx/issues/194
- https://github.com/google/zx/issues/152
- https://github.com/google/zx/issues/125
- https://github.com/google/zx/pull/114
- https://github.com/google/zx/issues/110
- https://github.com/google/zx/issues/75
Issue Analytics
- State:
- Created a year ago
- Reactions:54
- Comments:16 (1 by maintainers)
I’ve found success using the
.mts
extension and adding the following shebang:Run like so:
But will be cool if zx also supports global ts scripts.