Now that ES Modules are native to Node 14, it's time to make TypeScript generate ES Modules code, even though it doesn't support the .mjs extension yet.
Goals
- emit modern ES modules code
- import modules that use Node built-ins
- import modules that don't have named exports (e.g.
apollo-server
,node-influx
) - import your own modules without specifying an extension
- lint with ESLint, with TypeScript support
- test the TypeScript code instantly without having to build first
- run the resulting JavaScript code, with support for the optional chaining operator
?.
Bonus: continuous integration script for GitHub Actions. It automatically runs tests on every pushed commit.
Bonus points: don't use Babel!
OK, here's how to do this. We'll start with a new Node project (npm init
) and a standard tsconfig.json
file, then highlight the important changes.
You can see the complete template repo at
https://github.com/dandv/typescript-modern-project
Emit ES modules code
In tsconfig.json
, set this under compilerOptions
:
"target": "esnext",
"module": "esnext", // Output `import`/`export` ES modules
Import modules that use Node built-ins (http
, url
etc.)
- run
npm install --save-dev @types/node
- in
tsconfig.json
undercompilerOptions
, set-
"moduleResolution": "node"
, sotsc
can find modules when targeting ES6+ -
"types": ["node"]
to avoid errors related to Node built-in modules
-
Import modules that don't have named exports
Normally we could write in TypeScript
import { InfluxDB } from 'influx';
but when generating ES modules code, that statement will be passed through as is, and will cause Node to fail with
SyntaxError: The requested module 'influx' does not provide an export named 'InfluxDB'
because node-influx
doesn't provide named exports (and neither does an even more popular module, apollo-server
).
One alternative would be to generate old ugly commonjs modules code by,
- removing the
"type": "module"
line frompackage.json
, and - changing the module line to
"module": "CommonJS"
intsconfig.json
(allowSyntheticDefaultImports
also becomes unnecessary)
What we'll do is import the entire module:
import Influx from 'influx';
const influx = new Influx.InfluxDB();
However, this will generate Error TS1192: Module '...' has no default export.
To prevent that, set "allowSyntheticDefaultImports": true
in tsconfig.json
.
Import your own modules without specifying an extension
When transpiling, TypeScript won't generate an extension for you. Run Node with the node --experimental-specifier-resolution=node
parameter:
node --experimental-specifier-resolution=node run.js
Otherwise, node mandates that you specify the extension in the import
statement.
To support optional chaining, add the --harmony
flag to the node command line.
Run the resulting JavaScript code
Add "type": "module"
to package.json
, because TypeScript can't generate files with the .mjs extension.
ESLint
To be able to run eslint
, we must create an .eslintrc.cjs
file, rather than a .js
one (due to "type": "module"
in package.json
). Then, install the required dependencies:
npm i -D eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser
Here's the diff to add ESLint support.
Testing with Jest
The cleanest way to fully support Jest with TypeScript, ES Modules and ESLint, is to use ts-jest
, which has a number of advantages over using Babel. What we need to do:
-
npm install --save-dev jest @types/jest eslint-plugin-jest
- for ES Lint support - add
"jest"
to thetypes
array intsconfig.json
- add the
'jest'
plugin to.eslintrc.cjs
and also add'jest/globals': true
to itsenv
key - use the
jest.config.cjs
generated by ts-jestconfig:init
(just renamed .js -> .cjs).
Normally, to run Jest from package.json
, we'd add a "test": "jest"
line. That won't be sufficient, because we need to pass the --harmony
flag to node
(for optional chaining support).
To pass parameters to Node when running Jest, we'll use the following test
line:
"test": "node --harmony node_modules/.bin/jest"
The only caveat here is that Jest seems to prefer generated .js
files over their .ts
originals, so we'll exclude them via jest.config.cjs
:
testRegex: '.*.test.ts', // test filenames matching this regex
moduleFileExtensions: ['ts', 'js'], // modules are only in .ts files, but 'js' *must* be specified too
Source maps
If your script generates an error, you'll see the line numbers from the generated .js
files, which is not helpful. We want to see the original paths and line numbers from the .ts
files. To do that, we'll add sourceMap: true
to tsconfig.json
, install source-map-support
and run node with the -r source-map-support/register
parameter. Note that Jest already takes care of source mapping so you'll see the .ts
line numbers without having to do anything extra.
Here's the diff to add source map support.
CI testing
Using GitHub Actions, we can configure automatic testing via .yml
files under .github/workflows. You'll see a ✔️ in the repo when tests pass.
TODO
The tsconfig.json
settings generate .js
built files, and .js.map
source map files, next to the original .ts
file. While some IDEs conveniently hide these files, it may be desirable to output them in a separate directory, typically dist
.
This can be done using the rootDir
/outDir
settings in tsconfig.json
, but that sort of setup comes with an annoying limitation of Typescript that forbids importing files outside the rootDir
. That can be a problem with monorepos.
Wrap up
That's what you need to do, in order to get modern TypeScript code to produce modern ES Modules. Quite a lot of stuff. Thankfully, Deno compiles TypeScript natively, so I'm looking forward to those bright days 🌞
Top comments (0)