DEV Community

Cover image for Modern TypeScript project template
Dan Dascalescu
Dan Dascalescu

Posted on

Modern TypeScript project template

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.


  • 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

Emit ES modules code

In tsconfig.json, set this under compilerOptions:

    "target": "esnext",
    "module": "esnext",  // Output `import`/`export` ES modules
Enter fullscreen mode Exit fullscreen mode

Import modules that use Node built-ins (http, url etc.)

  • run npm install --save-dev @types/node
  • in tsconfig.json under compilerOptions, set
    • "moduleResolution": "node", so tsc 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';
Enter fullscreen mode Exit fullscreen mode

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 from package.json, and
  • changing the module line to "module": "CommonJS" in tsconfig.json (allowSyntheticDefaultImports also becomes unnecessary)

What we'll do is import the entire module:

import Influx from 'influx';
const influx = new Influx.InfluxDB();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.


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
Enter fullscreen mode Exit fullscreen mode

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 the types array in tsconfig.json
  • add the 'jest' plugin to .eslintrc.cjs and also add 'jest/globals': true to its env key
  • use the jest.config.cjs generated by ts-jest config: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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.


The tsconfig.json settings generate .js built files, and 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)