Modern TypeScript project template
Tags: typescript, esmodules, eslint, jest
Published: 2020-07-27 03:00:47

---
title: Modern TypeScript project template
published: true
description: How to generate ES Modules code from TypeScript, test it fast without building, and lint everything
tags: TypeScript, ESModules, ESLint, Jest
cover_image: https://user-images.githubusercontent.com/33569/88499103-ca491100-cf79-11ea-826a-f9efd452f90e.png
---

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](https://github.com/microsoft/TypeScript/issues/18442#issuecomment-581738714).

# 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`](https://github.com/apollographql/apollo-server/issues/1356#issuecomment-565277759), [`node-influx`](https://github.com/node-influx/node-influx/issues/298))
* 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`](https://github.com/dandv/typescript-modern-project/blob/master/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`:

```json
    "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` under `compilerOptions`, set
  * `"moduleResolution": "node"`, so `tsc` can find modules [when targeting ES6+](https://github.com/Microsoft/TypeScript/issues/8189) 
  * `"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](https://github.com/node-influx/node-influx/issues/298) (and neither does an even more popular module, [`apollo-server`](https://github.com/apollographql/apollo-server/issues/1356#issuecomment-565277759)).

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:

```js
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](https://github.com/microsoft/TypeScript/issues/16577). 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](https://nodejs.org/api/esm.html#esm_mandatory_file_extensions) 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](https://github.com/microsoft/TypeScript/issues/18442#issuecomment-581738714).


# 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](https://github.com/dandv/typescript-modern-project/commit/f816fe6e8d83ce554bd3066ac6638fb4406e917f).
 

# 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](https://github.com/kulshekhar/ts-jest#ts-jest). 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`](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"

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`](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`](https://www.npmjs.com/package/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](https://github.com/dandv/typescript-modern-project/commit/4e31278833f2ce07f474d9c6348bb4509082ee97).


## CI testing

Using [GitHub Actions](https://github.com/features/actions), we can configure automatic testing via `.yml` files under [.github/workflows](.github/workflows). You'll see a ✔️ in [the repo](https://github.com/dandv/typescript-modern-project) 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](https://github.com/microsoft/TypeScript/issues/9858) of Typescript that [forbids importing files outside the `rootDir`](https://stackoverflow.com/questions/52121725/maintain-src-folder-structure-when-building-to-dist-folder-with-typescript-3). That can be a [problem with monorepos](https://github.com/microsoft/TypeScript/issues/17611).


## 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 🌞