DEV Community

Hamish Milne
Hamish Milne

Posted on

A long-winded Primer to the JavaScript Packaging Situation

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 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, 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:

import { foo } from "foo";

export async function bar() {
    const { baz } = await import("baz");
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

module.exports = { bar }
Enter fullscreen mode Exit fullscreen mode

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, rejoice - you can mix and match both systems in the same file:

import { foo } from "foo";
const { bar } = require("bar");
Enter fullscreen mode Exit fullscreen mode

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:

import foo from "./foo.cjs"; // Works!
const { bar } = require("./bar.mjs"); // Error!
Enter fullscreen mode Exit fullscreen mode

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 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?

import { foo } from "./foo"; // Works... sometimes!
Enter fullscreen mode Exit fullscreen mode

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, and Node's require 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 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 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 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 imports into requires, 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 goes into detail, but the short version is that, when importing, you should be explicit about importing a type, compared to a runtime value.

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

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:

{
    "extends": "./tsconfig.json",
    "compilerOptions": {
        "verbatimModuleSyntax": false,
        "module": "CommonJS"
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

// 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");
Enter fullscreen mode Exit fullscreen mode

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:

module.exports = { foo, default: bar };
Enter fullscreen mode Exit fullscreen mode

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:

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

// index.js
const PackageFactory = require("package");
const myPackage = PackageFactory();
Enter fullscreen mode Exit fullscreen mode

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 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.

import * as moment from "moment";
import moment from "moment"; // allowed when allowSyntheticDefaultImports=true
Enter fullscreen mode Exit fullscreen mode

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:

Object.defineProperty(exports, "__esModule", { value: true });
Enter fullscreen mode Exit fullscreen mode

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, and set your include patterns appropriately. This will also enable 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. 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 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 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 in yarnrc, for the reasons mentioned above about Node's ESM support sucking.

The only ts-node specific option I'd recommend is 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, 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 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 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:

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

// .js
const foo = require("foo");
module.exports = { bar };
Enter fullscreen mode Exit fullscreen mode

... 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 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 or esbuild's 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')
Enter fullscreen mode Exit fullscreen mode

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:

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

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. Any browser version released after 2016, eight years ago, fully supports it.

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. However, Jest is kind of enormous and brittle with a huge amount of configuration for some reason, and in my experience getting both TypeScript and ESM to work with Jest really quite painful.

For that reason, I highly recommend Vitest. 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

Top comments (0)