A long-winded Primer to the JavaScript Packaging Situation
Tags: javascript, typescript, npm
Published: 2024-06-22 11:20:35

If you've been dealing with the JavaScript ecosystem for more than 5 minutes you're probably aware that the situation with modules, build systems, runtimes, bundlers, and packaging code for distribution is kind of a mess right now. The aim of this guide is to cut through some of the bullshit, give a bunch of somewhat opinionated recommendations and end with a simple decision tree to let anyone determine what to write, what to distribute, and how to do it.

First off, some key assumptions (aka, my opinions):

## 0. These are (just) my opinions

I believe, quite strongly, that the approach outlined here should be used for all new JS projects. There will, *inevitably*, be certain niche or legacy situations where you will *have* to, for example, use UMD modules or TypeScript's 'import require' syntax. I'll try to note any examples where I've personally come across them, and of course feel free to comment your own if you have any.

## 1. We are using TypeScript

Seriously, at this point there is *precious* little reason to not use TypeScript as your source language. The tooling is robust, the language itself is excellent, there's wide support in the ecosystem, and it's got first-class support in Bun (i.e. the probable NodeJS replacement).

If you *really* love runtime errors and want to see more of them in your code, you can set [`"strict": false`](https://www.typescriptlang.org/tsconfig#strict) in tsconfig and just... write JavaScript. Then, to be serious for a moment, you can gradually adopt type annotations as your project grows - and you'll have the systems in place to support it.

In a few places, I'll be mentioning [`ts-node`](https://typestrong.org/ts-node/), which is a module for NodeJS that adds a special 'loader' for TypeScript files. In essence, if you run Node with the right options (`node --loader ts-node/esm` typically) you'll get pretty robust, seamless TypeScript support - no need for separate build steps. If it's at all applicable, you should use it.

With all that said, I highly recommend *against* *distributing* raw TypeScript. At the end of the day, TypeScript is still an additional dependency that we don't want to impose on library consumers. There's also a plethora of configuration options that may cause distributed TypeScript to simply not work in a given downstream project.

## 2. We don't care about UMD, AMD, SystemJS etc.

For those not in the know, there are two 'module systems' (i.e. ways of telling the runtime what values to import and export from a given file) in common use right now. The normal, standard-since-2015, fully-featured, widely-adopted 'ECMAScript Module' syntax, or ESM for short:

```js
import { foo } from "foo";

export async function bar() {
    const { baz } = await import("baz");
}
```

and the old, outdated, legacy system used by NodeJS, called CommonJS (or CJS):

```js
const { foo } = require("foo");

function bar() {
    const { baz } = require("baz");
}

module.exports = { bar }
```

There were once a whole plethora of alternative module systems: UMD, AMD, and SystemJS, but these are now historical footnotes. The only time you will encounter them is if someone has packaged a library using them (God help you!). You should never, in the current year, distribute code that uses them.

And to be absolutely clear: the only reason we are even mentioning CommonJS is because the NodeJS implementation of ESM has, at the time of writing, several critical compatibility issues that are unlikely to be resolved in the near future, which will be explained in the relevant sections later on.

And if you're using [Bun](https://bun.sh/), rejoice - you can mix and match both systems in the same file:

```js
import { foo } from "foo";
const { bar } = require("bar");
```

Not that you would want to, of course, but it's probably the best solution to the problems we'll be discussing.

## 3. We are aiming for simplicity, compatibility, and robustness

* We want our emitted JS code to be as close to our TypeScript as possible.
* We don't want superfluous distribution files, or long build processes, or a load of extra dependencies.
* We want the code to work in every situation - as long as it doesn't conflict with the first two goals.

# The problem with ESM in NodeJS

NodeJS uses CommonJS internally, and probably always will. Node's ESM support uses a special API hook called a 'loader', which is responsible for essentially translating your ESM files into CJS under the hood.

There are, of course, several problems with this approach. Notably, you can't really configure it so that multiple loaders act on the same file, as a user. `ts-node` has to use a special implementation to get ESM compatibility on Node, which is why you need `ts-node/esm`. Likewise for Yarn's PnP system (though in that case it's the default). If you want to use both together, you're SOL.

The second thing to remember is that ESM imports are inherently asynchronous, with top-level `await` and so on, whereas Node's `require` is a synchronous call. Evidently, Node's execution pipeline is inflexible enough that this presented an enormous problem for the devs, and the result: you can't `require` ESM files in Node. The reverse isn't true, fortunately - you can `import` CJS files no problem (well, some problems, but that's for later).

In short:

```js
import foo from "./foo.cjs"; // Works!
const { bar } = require("./bar.mjs"); // Error!
```

The upshot is if you're writing apps and tools, you can, and *should* run ESM natively. If you transpile to CJS, you won't be able to use ESM-only dependencies (without using `await import`, which is a *whole* other can of worms and will probably make your APIs unusable).

But for libraries, where you have no idea if they'll be imported from an ESM or CJS context, you'll need to distribute your package as CJS by transpiling your ESM code (with `tsc`, ideally). Note that in this case, **ESM-only dependencies will not work** but they are, fortunately, quite rare.

In all cases I've found, this transpiled CJS code will work just fine in bundlers like Webpack and Vite - in the latter case it'll get converted *back* to ESM, funnily enough. The sole exception is Deno, which *does* use ESM internally and won't like your CJS code. If you want to support Deno, bundlers, *and* Node at runtime, there's no way around it: you'll have to multi-target CJS and ESM. But we'll get to that later.

In conclusion: Node's ESM support sucks. At the very, very least, it's now officially 'stable'; the 'experimental' warnings you might remember in the Node CLI have gone away as of v20.

# Configuration recommendations

I'm going to be covering a few different types of JS project:

* Frontend/web apps, [IIFE](https://en.wikipedia.org/wiki/Immediately_invoked_function_expression) bundles
* Backend/server-side apps, tools, and local scripts
* Libraries
  * Some of which may need to run on NodeJS

Each of these has different requirements, so keep them in mind as you follow along.

## A note on import statements and file extensions

What's the correct way to write imports of local files? It's easy, right?

```js
import { foo } from "./foo"; // Works... sometimes!
```

The thing to remember is that **module resolution is not standardised across consumers**. All you're doing is telling whatever program is consuming your code - `tsc`, Node, Webpack, etc. - to please give you the module object for `./foo`, whatever that is. This is how when you import `react`, Node will look inside `node_modules` and load `node_modules/react/index.js` or whatever.

First off, if you're importing a local file, always begin the import specifier with `./` or `../`; this ensures there's no ambiguity with packages, and in my experience, makes tooling much more reliable. Don't try to mess with tsconfig to add multiple source roots, or do `src/foo` or somesuch - you'll end up tying yourself in knots and run into all sorts of errors down the line. Stick to relative paths.

Secondly, you need to be extremely cognizant of file extensions. This applies both to local files, and when deep-linking into a package to import a particular file (but not to directories or package roots). TypeScript, bundlers, [Bun](https://bun.sh/docs/runtime/modules#syntax), and [Node's `require`](https://nodejs.org/api/modules.html#modules_file_modules) statements will search for the file in question, trying different file extensions in a particular order until they get a match.

Deno and Node's ESM implementation, however, do *not* do this. They require the file to be specified exactly, including the specific file extension to use.

Sounds reasonable - in the former cases, you aren't *forbidden* from specifying the extension, it's just optional. So we just need to start using `import "./foo.js"` and everything should still work... right?

But we aren't writing JavaScript. We're writing TypeScript. So what should we use to import a local file - `.ts`, or `.js`?

The sad reality is that no option will work in all cases.

In the IDE, you can use either: for `.js`, TypeScript will 'magically know' to convert this to `.ts` under the hood... ugh. You can also leave off the extension entirely, if TypeScript is configured to let you do this.

If you're using `ts-node` with `type: module`, or Deno with TypeScript directly, you have to use `.ts` because the `.js` files don't exist on disk (the runtime will take care of the conversion).

If you're manually transpiling to ESM JavaScript, then running on Node, you have to use `.js`, because of course the `.ts` files don't exist (and Node wouldn't understand them in any case).

If you're manually transpiling to CJS, you can use `.js`, or leave off the extension.

If you're using a bundler that accepts TypeScript directly, like Webpack, you'll have to use `.ts` because the `.js` doesn't exist on disk. There are probably ways to rectify this with custom resolution rules etc. but that's out of scope for now.

One 'simple' fix that comes to mind is simply rewriting import statements at build time from `*.ts` (or blank) to `*.js`. The TypeScript devs, however, [have explicitly rejected this](https://github.com/microsoft/TypeScript/issues/49083#issuecomment-1435399267) as a solution - and their reasons do seem pretty solid.

So, what's the TL;DR here?

For libraries, **you should specify `.js`** because you'll be building JS files from your TS source anyway.

For bundlers/front-end, **you should leave off the extension, or use `.ts`**. In this case you may need to set `"moduleResolution": "Bundler"` as described below.

For server-side that uses `ts-node`, **you should specify `.ts`**.

For server-side where you're pre-building your JS, **you should specify `.js`**.

## `type`, `module`, and `moduleResolution`

These three fields, the first in package.json and the others in tsconfig.json, determine and are determined by the module system your compiled JavaScript will use. Despite them being three separate fields they are in fact very tightly linked, and only certain combinations will work.

`type` is used by both Node and TypeScript to decide what module system `.js` and/or `.ts` files use. If left unset, or set to `"commonjs"` it will assume they're CJS. If set to `"module"` it will assume they're ESM.

* For libraries targeting Node and therefore emitting CJS, you should set it to `commonjs`.
* In all other cases, use `module`.

The setting can be overridden on a per-file basis by using `.cjs` and `.cts` to for CJS code, and `.mjs` and `.mts` for ESM code. This is useful, but these extensions are not as widely-supported and there are pitfalls associated with using them.

[`moduleResolution`](https://www.typescriptlang.org/tsconfig#moduleResolution) determines how TypeScript resolves type information from an `import` operand, and ensures that these operands are specified correctly.

* For server-side, we need to use `Node16`, since this allows us to import both ESM and CJS and will give compile errors when we fail to specify a file extension.

* For libraries building to CJS, we need to use `Node10`, since that's the only option allowed when doing so.

* For front-end, or libraries only targeting front-end, you should use `Bundler`, which is similar to `Node16` but more permissive.

[`module`](https://www.typescriptlang.org/tsconfig#module) determines which module system `tsc` will use in the emitted JS.

* For server-side, we need to use `Node16` because this is the only option when `moduleResolution` is correctly set for this.

* For libraries building to CJS we should, of course, use `CommonJS`.

* For other libraries, you should use `ES2015`.

* And for front-end you can use `ES2022`, or otherwise the latest version.

## So, what do I save my source files as?

If you follow the steps above, you should save your files as `.ts`, as normal.

## `target` and `lib`

`target` tells `tsc` whether it needs to down-level newer JavaScript features to support older runtimes.

For any app or tool in which you control the runtime environment, you should set it to the latest: `ES2022` at the time of writing.

For libraries, or code that gets distributed like a dev tool, you should start with `ES2018` (essentially ES2015 but with async functions and object-spread expressions, both supported since Node v10) and only increment it as needed.

The `lib` option should be kept in sync with `target` (aside from the addition of `DOM`, `DOM.Iterable` for front-end).

## `isolatedModules` and `verbatimModuleSyntax`

Turning TypeScript into JavaScript is, in most cases, quite straightforward: find any syntax that related to the type system, and delete it. This works because TypeScript was always designed to be a strict superset of JavaScript, and it's great because the JavaScript we emit is extremely close to the source code. So if sourcemaps aren't working you can still debug, and you don't need to worry about `tsc` introducing bugs into your code.

Of course if you're targeting CJS you need to turn all your `import`s into `require`s, but that's pretty straightforward to do, and in any case we'll deal with the pitfalls later on.

When `tsc` runs, it needs to load up every file in the project in order to type-check everything. If successful, it then emits the JS code for every file. Other tools, like swc, esbuild, and Vite, don't do this: they process each file individually and in parallel, without type-checking, often in response to those specific files being changed. As you can imagine this is enormously faster. You can then run `tsc` on a slower cadence, without emitting JS. This is known as 'SFC' for Single-File Compilation.

However, there are a couple of TypeScript language features that don't fit this pattern: `namespace` and `const enum`. I won't go into details here, but suffice it to say that these are now largely considered mistakes, and will often cause errors if your TypeScript is consumed by anything other than `tsc`.

**To fix this, you should always enable `isolatedModules` in tsconfig, which makes it an error to use any of these features.**

The second problem relates to importing types. [The documentation](https://www.typescriptlang.org/tsconfig#verbatimModuleSyntax) goes into detail, but the short version is that, when importing, you should be explicit about importing a type, compared to a runtime value.

```typescript
// Instead of this:
import { some_type, some_value } from "foo";
import { other_type } from "bar";

// Use this:
import { type some_type, some_value } from "foo";
import type { other_type } from "bar";
```

What's the difference between `import type { foo }` and `import { type foo }`? The first one will get removed entirely from the output, whereas the second one will become `import {}`. This is crucial to remember if module initialization order matters - for example if a module has any side effects (it's extremely bad practice, but... it happens).

My personal advice: if you're importing modules with side effects, use `import "foo";` with a comment to explain; ideally at the very top of your program.

**The setting `verbatimModuleSyntax` will helpfully enforce the use of `import type` for us, so you should absolutely enable it in tsconfig.**

BUT!

When `verbatimModuleSyntax` is enabled, `tsc` will - *bafflingly* - refuse to emit CJS code! The documentation implies that this is a deliberate design choice, which is very silly indeed.

This means that, if we're targeting CJS, we need to do a little dance with the compiler by defining a secondary tsconfig that we'll call `tsconfig-cjs.json`:

```json
{
    "extends": "./tsconfig.json",
    "compilerOptions": {
        "verbatimModuleSyntax": false,
        "module": "CommonJS"
    }
}
```

We'll then need to set `"module": "ES2018"` in the main tsconfig, so that the IDE doesn't complain. The build command will be `npx tsc -p ./tsconfig-cjs.json`.

## LFANs, `esModuleInterop`, and `allowSyntheticDefaultImports`

Normally, when converting ESM to CJS the rules are pretty straightforward:

```js
// src/index.ts
import { foo } from "foo";
import * as bar from "bar";
import baz from "baz";
import bat1, { bat2 } from "bat";

// dist/index.cjs
// ... Okay, it's not *exactly* this, there's normally some extra cruft, but semantically this is what you get.
const { foo } = require("foo");
const bar = require("bar");
const { default: baz } = require("baz");
const { default: bat1, bat2 } = require("bat");
```

Note a couple of things here:

* a 'Default Import' (`import baz`) in ESM is the same as accessing the `default` property of the module object in CJS
* a 'Namespace Import' (`* as bar`) in ESM is the same as just assigning the whole module to a variable in CJS

These are both true because the CJS export syntax is simply:

```js
module.exports = { foo, default: bar };
```

This is all fine, but CJS doesn't restrict `module.exports` to only objects - it can be anything you like, a string, a number, a function. Indeed, a common pattern historically was to define modules like so:

```js
// package.js
module.exports = function PackageFactory() { }

// index.js
const PackageFactory = require("package");
const myPackage = PackageFactory();
```

**Let's call this a... Legacy Function-As-Namespace Module. LFAN for short.**

Of course, it's not possible to define LFANs in ESM, but we can at least consume them: simply use `import * as PackageFactory from "package"`.

However... this is *technically* invalid according to the ESM spec! ESM namespaces are immutable object-like things and must *never* be callable!!!1!

Therefore, transpilers like `tsc`, `esbuild`, `swc`, and Babel, will usually [generates a lot of cruft](https://www.typescriptlang.org/tsconfig#esModuleInterop) in CJS output in order to 'fix' this 'problem', which is controlled with the `esModuleInterop` setting.

There are, roughly speaking, two methods used to do this:

* When importing a CJS module which has no `default` field, set `module.exports.default = module.exports`, OR
* Create a new, immutable object, copying over only the owned properties of `module.exports`, if any, and setting the value of `default` to `module.exports` itself.

`tsc`, `esbuild`, and `swc` use the first option, which is more efficient and compatible, since it ends up that a Default Import and a Namespace Import will provide the same value. Node's ESM runtime and Babel use the second, which is more spec-compliant, since *only* a Default Import will work - a Namespace Import will return an object like `{ default: moduleFunction }`, which is probably not what you expect.

From what I can tell, only `tsc` allows you to turn this 'feature' off entirely, by disabling `esModuleInterop`.

TypeScript's `allowSyntheticDefaultImports` mirrors this runtime behaviour in the type system. When enabled, TypeScript relaxes its checking of import statements very slightly, such that for any module that lacks an explicit 'default' export, a 'synthetic' one is created that just aliases the whole module. Unfortunately there's no way to forbid Namespace Imports in this situation, so if you're targeting Node's ESM or Babel you just need to be careful.

```js
import * as moment from "moment";
import moment from "moment"; // allowed when allowSyntheticDefaultImports=true
```

So, in general:

* If you know that you have no LFAN dependencies, OR
* You are only targeting CJS, and don't intend to run your source code through `esbuild`, `swc`, or Babel,

then you can (and should) disable both `esModuleInterop` and `allowSyntheticDefaultImports`. **Otherwise, you will have to enable them both to prevent compatibility issues down the line, and be sure to only use Default Imports for LFANs**.

Note that, for CJS emits, TypeScript will always output the following:

```js
Object.defineProperty(exports, "__esModule", { value: true });
```

This just sets a flag that allows consumers that use `esModuleInterop` (and similar) to be a little more efficient.

## Other, miscellaneous options

You should enable [`composite`](https://www.typescriptlang.org/tsconfig#composite), and set your `include` patterns appropriately. This will also enable [`incremental`](https://www.typescriptlang.org/tsconfig#incremental) (making your builds faster), so remember to add `.tsbuildinfo` to gitignore. If all your code is in some directory, e.g. `src`, make sure to set `rootDir` to that aforementioned directory.

You will probably want to enable [`skipLibCheck`](https://www.typescriptlang.org/tsconfig#skipLibCheck). Packages can include all sorts of TypeScript code which may or may not be valid for your current TypeScript version and compilation options, so using this saves a lot of headaches. Note you'll still have type-checking on your *imports* of library code - this just tells `tsc` to not bother checking the library declaration files themselves for correctness.

There are various other useful options like `forceConsistentCasingInFileNames` but these all come with sensible defaults.

And finally you should, of course, enable [`strict`](https://www.typescriptlang.org/tsconfig#strict) in tsconfig.

# Build, run, distribute

## You should use `ts-node`

**For server-side apps, or local scripts, this is without a doubt the best approach.** It's simple, robust, and removes whole classes of potential errors.

With everything set up as described above, you'd just run `node --loader ts-node/esm ./my_file.ts`. Debugging in VSCode 'just works'. I like to use the JavaScript Debug Terminal as it saves having to create `launch.json`.

If `ts-node` is all you need, you should enable [`noEmit`](https://www.typescriptlang.org/tsconfig#noEmit) in tsconfig, and use `tsc` as essentially a linter. This prevents JavaScript files from being created accidentally.

If you're using Yarn, remember to set [`nodeLinker: node-modules`](https://yarnpkg.com/configuration/yarnrc#nodeLinker) in yarnrc, for the reasons mentioned above about Node's ESM support sucking.

The only `ts-node` specific option I'd recommend is [`transpileOnly`](https://typestrong.org/ts-node/docs/options/#transpileonly). You probably don't need `swc`, but if startup performance is really critical you might consider it.

There are of course certain situations where `ts-node` isn't an option. Maybe you *really* want to use Yarn PnP, or you're distributing a dev tool where you want to minimise your runtime dependencies. In that case...

## Emitting ESM

**You'll need to do this for dev tools, and for libraries which don't target Node as a runtime (or do, but have ESM-only dependencies).**

With everything configured as above, just set [`outDir`](https://www.typescriptlang.org/tsconfig#outDir), and run `tsc` to build. If you take a look at the emitted JavaScript you'll see that it's extremely close to your source code.

For an app, you can then run `node ./dist/main.js` as normal. You should explicitly set [`declaration`](https://www.typescriptlang.org/tsconfig#declaration) to false, since you're not expecting anyone to consume your type information, and it'll speed up your build process.

For a library, you'll want to set two fields in package.json, `main` and `types`, which should point to `/dist/index.js` and `/dist/index.d.ts` respectively. This allows consumers to import your library and get the root module.

## Emitting CJS

**You'll need to do this for libraries that target Node, but not Deno, and don't have any ESM-only dependencies.**

After following the steps described in the `verbatimModuleSyntax` section above, you should already have a `tsconfig-cjs.json` file. Simply run `npx tsc -p ./tsconfig-cjs.json` and you'll have your output.

## Multi-targeting CJS and ESM

**You'll need to do this if you want to target both Node and Deno.**

Since CJS is the 'lowest common denominator' it will be your primary target, with ESM being built specifically for Deno. Follow all the steps above assuming you're targeting CJS alone.

In `tsconfig-cjs.json`, set your `outDir` to `dist/cjs` or similar. Set `main` and `types` in package.json accordingly. Then in your main `tsconfig.json`, set `outDir` to `dist/esm` or similar.

We can then run `npx tsc` for ESM, and `npx tsc -p ./tsconfig-cjs.json` for CJS.

Remember: since we've set `type: commonjs`, if you try to import the contents of `dist/esm` in Node you'll get an error. Of course there's no reason to do this when the CJS target works just fine and is more compatible in any case. The only consumers of this target will be those that outright do not support CJS (i.e. Deno).

Newer versions of Node use a field called [`exports`](https://nodejs.org/docs/latest-v18.x/api/packages.html#conditional-exports) in package.json, which is supposed to allow CJS contexts to get a CJS module, and ESM contexts to get an ESM module. I don't see much value in this when we know CJS works all the time; it just seems like it'll make testing harder.

# Things to avoid

## Don't use `export =` or `import foo = require`

Before TypeScript supported ESM, there was some special syntax to define a CJS module's imports and exports:

```typescript
// .ts
import foo = require("foo");
export = { bar };

// .js
const foo = require("foo");
module.exports = { bar };
```

... Yep, that's really all it does. Honestly I don't see the point.

In any case, this syntax is still available today, and it's the only way to define a 'raw' CJS module in TypeScript, rather than an ESM module which is then converted to CJS. As you might imagine, **you can only target CJS when using this syntax**.

Furthermore, though the [documentation](https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require) doesn't call this out, there's no way to import or export type-only declarations (`type` and `interface`) with this syntax.

There are exactly two reasons why you'd want to use this rather than ESM:

* You really hate the presence of `Object.defineProperty(exports, "__esModule", { value: true });` in the output (with this syntax it won't be emitted)
* You want to define an LFAN, so you need a way to directly control the value of `module.exports`

Neither of these are very good reasons.

## Don't bundle libraries for distribution

Some library authors follow the practice of bundling some or all of a library's local files and/or package dependencies into the output. There is really only one valid purpose for doing this, and that's when you're creating a bundle for legacy web development - the sort where you write `<script>` tags to include your dependencies then manually bash out the JavaScript.

This is, of course, a very outdated technique and should be discouraged at every opportunity in favour of just... using NPM. However, if it does become necessary, you need to create a global-setting IIFE bundle, as with Webpack's [`type: global`](https://webpack.js.org/configuration/output/#type-global) or `esbuild`'s [global name](https://esbuild.github.io/api/#global-name). This bundle can be distributed in addition to a standard NPM package. You should under no circumstances use something like UMD; CommonJS consumers should always use packages.

In all other cases, there is absolutely no reason to do this. It complicates dependency management, makes debugging harder, and requires additional tooling for no real benefit in kind.

## Be extremely careful with `await import`

The `await import` syntax is ESM's method of defining a 'dynamic import' - that is, an import that happens during the program flow, rather than immediately on startup.

The most common use of this syntax is for 'code splitting' on the front-end: JS bundles tend to be rather large, so this allows you to lazy-load parts of your app to make your initial page load faster (the bundler is responsible for figuring out how this works underneath).

The other use case is specific to NodeJS. The Node runtime provides `await import` in ESM contexts... **but also in CJS contexts**. And, unlike `require`, **you can import *any* kind of module using it**, both ESM and CJS. So the upshot is that, under Node, this is the only way to import an ESM module into a CJS context.

Let's consider an example dependency diagram:

```
main.mjs
┣━ import 'A.mjs'
┃  ┗━ import 'B.cjs'
┃     ┗━ require('C.cjs')
┗━ import 'D.cjs'
   ┗━ await import('E.mjs')
```

In order to import module E in the context of module D, we have to use `await import`. It's a useful escape hatch for the very few cases where you have no other options.

However, you do have other options!

* **If you're making a server-side app, just use ESM directly as recommended earlier.** There's no reason not do do this now that Node's ESM is considered stable.
* **If you're making a library, and you *really* can't get rid of this ESM-only dependency, just make your library ESM-only too.** While this does just kick the problem further up the chain, your consumers *can also* just use ESM directly. In other words, if anyone complains, direct them to the point above.

The problem is that `await import` is really, really awful to use outside of code-splitting. You end up with code like this:

```js
// there are other ways, but this is probably the most elegant
async function doImports() {
  return {
    bar: await import("bar.mjs"),
    baz: await import("baz.mjs"),
  }
}

// note that all our exports need to be async functions now.
// want to export sync functions, or constants? You're SOL.
export async function myFunc() {
  const { bar } = await doImports();
  // etc.
}
```

This is, to put it lightly, horrible.

## Don't downlevel to ES5

ES2015, aka ES6, has been standardised since (as the name implies) 2015. NodeJS has been [99% compliant since version 8](https://node.green/). Any browser version released after 2016, **eight years ago**, [fully supports it](https://caniuse.com/?search=es6).

The *only* reason to target ES5 is to support ancient, insecure, unsupported browsers like IE11. If this applies to you, I understand that it's unlikely to be by choice, so you have my sympathies :)

# A note on unit testing

Traditionally, the received wisdom for TypeScript unit testing was to use [`ts-jest`](https://kulshekhar.github.io/ts-jest/). However, Jest is kind of enormous and brittle with a huge amount of configuration for some reason, and in my experience getting both [TypeScript](https://kulshekhar.github.io/ts-jest/docs/guides/esm-support/) and [ESM](https://jestjs.io/docs/ecmascript-modules) to work with Jest really quite painful.

For that reason, **I highly recommend [Vitest](https://vitest.dev/)**. It's compatible with Jest's API and has both TypeScript and ESM support out of the box.

**If you really have to use Jest for some reason, you should target CJS** and save yourself a lot of headaches.

# In summary...

## In all cases

* Install `typescript` as a dependency
* Save your files as `.ts`
* Write normal TypeScript using the ESM syntax
* When importing a local file, always use a relative path starting with `./`
* In tsconfig:
  * Set `strict` to `true`
  * Set `isolatedModules` to `true`
  * Set `verbatimModuleSyntax` to `true`
  * Set `skipLibCheck` to `true`
  * Set `composite` to `true`, making sure to set `include` and `rootDir`

## Frontend/web apps (or anything using a bundler)

* Refer to your specific bundler (e.g. Webpack, Vite, esbuild) for rules about file extensions in import statements
* In package.json, set `type` to `"module"`
* In tsconfig:
  * Set `module` and `target` to `"ES2022"` (or newer)
  * Set `moduleResolution` to `"Bundler"`
  * Set `allowSyntheticDefaultImports` to `true`
  * Set `noEmit` to `true`
* Let the bundler consume your TypeScript directly

## Libraries that are only used by Web apps, Bun, Deno etc., or target Node but have ESM-only dependencies

* Use `.js` file extensions when importing local modules
* In package.json:
  * Set `type` to `"module"`
  * Set `main` to `"./dist/index.js"`
  * Set `types` to `"./dist/index.d.ts"`
* In tsconfig:
  * Set `module` and `moduleResolution` to `"Node16"`
  * Set `target` to `"ES2018"` (or `ES2015` if absolutely required)
  * Set `lib` to the smallest required set of libraries
  * Set `allowSyntheticDefaultImports` to `true`
  * Set `outDir` to `dist`
  * Set `sourceMap` to `true`
* Run `npx tsc` to build

## Libraries that may be used by the NodeJS runtime

* Use `.js` file extensions when importing local modules
* In package.json:
  * Set `type` to `"commonjs"`
  * Set `main` to `"./dist/index.js"`
  * Set `types` to `"./dist/index.d.ts"`
* In tsconfig:
  * Set `module` to `"ES2015"`
  * Set `target` to `"ES2018"` (or `ES2015` if absolutely required)
  * Set `moduleResolution` to `"Node10"`
  * Set `lib` to the smallest required set of libraries
  * Set `outDir` to `dist`
  * Set `sourceMap` to `true`
  * Consider setting `esModuleInterop` and `allowSyntheticDefaultImports` to `false`, making sure to use Namespace Imports for LFANs
* Create a file `tsconfig-cjs.json`, extending the primary one, and in it:
  * Set `verbatimModuleSyntax` to `false`
  * Set `module` to `"CommonJS"`
* Run `npx tsc -p ./tsconfig-cjs.json` to build

## Libraries that may be used by the NodeJS runtime, but also want to target Deno

* Use `.js` file extensions when importing local modules
* In package.json:
  * Set `type` to `"commonjs"`
  * Set `main` to `"./dist/cjs/index.js"`
  * Set `types` to `"./dist/cjs/index.d.ts"`
* In tsconfig:
  * Set `module` to `"ES2015"`
  * Set `target` to `"ES2018"` (or `ES2015` if absolutely required)
  * Set `moduleResolution` to `"Node"`
  * Set `lib` to the smallest required set of libraries
  * Set `outDir` to `dist/esm`
  * Set `sourceMap` to `true`
  * Set `allowSyntheticDefaultImports` to `true`
  * If you have any LFANs as dependencies, or may do in future, set `esModuleInterop` to `true`; otherwise `false`
* Create a file `tsconfig-cjs.json`, extending the primary one, and in it:
  * Set `verbatimModuleSyntax` to `false`
  * Set `module` to `"CommonJS"`
  * Set `outDir` to `dist/cjs`
* Run `npx tsc -p ./tsconfig-cjs.json` to build the CJS target, and `npx tsc` to build the ESM

## Server-side apps and local scripts where you can use `ts-node`

* Use `.ts` file extensions when importing local modules
* Install `ts-node` as a dependency
* If using Yarn, set `nodeLinker` to `node-modules`
* In package.json, set `type` to `"module"`
* In tsconfig:
  * Set `module` and `moduleResolution` to `"Node16"`
  * Set `target` to `"ES2022"`
  * Set `verbatimModuleSyntax` to `true`
  * Set `allowSyntheticDefaultImports` to `true`
  * Set `noEmit` to `true`
* Start the app with `node --loader ts-node/esm src/main.mts`

## Dev tools, server-side apps that can't use `ts-node`

* Use `.js` file extensions when importing local modules
* In package.json, set `type` to `"module"`
* In tsconfig:
  * Set `module` and `moduleResolution` to `"Node16"`
  * Set `target` to `"ES2022"`
  * Set `verbatimModuleSyntax` to `true`
  * Set `allowSyntheticDefaultImports` to `true`
  * Set `outDir` to `dist`
  * Set `sourceMap` to `true`
* Use `npx tsc` to build your JavaScript before running your app as normal