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");
}
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 }
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");
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!
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!
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 toNode16
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 whenmoduleResolution
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 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";
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"
}
}
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");
Note a couple of things here:
- a 'Default Import' (
import baz
) in ESM is the same as accessing thedefault
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 };
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();
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, setmodule.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 ofdefault
tomodule.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
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 });
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 };
... 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')
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.
}
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
totrue
- Set
isolatedModules
totrue
- Set
verbatimModuleSyntax
totrue
- Set
skipLibCheck
totrue
- Set
composite
totrue
, making sure to setinclude
androotDir
- Set
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
andtarget
to"ES2022"
(or newer) - Set
moduleResolution
to"Bundler"
- Set
allowSyntheticDefaultImports
totrue
- Set
noEmit
totrue
- Set
- 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"
- Set
- In tsconfig:
- Set
module
andmoduleResolution
to"Node16"
- Set
target
to"ES2018"
(orES2015
if absolutely required) - Set
lib
to the smallest required set of libraries - Set
allowSyntheticDefaultImports
totrue
- Set
outDir
todist
- Set
sourceMap
totrue
- Set
- 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"
- Set
- In tsconfig:
- Set
module
to"ES2015"
- Set
target
to"ES2018"
(orES2015
if absolutely required) - Set
moduleResolution
to"Node10"
- Set
lib
to the smallest required set of libraries - Set
outDir
todist
- Set
sourceMap
totrue
- Consider setting
esModuleInterop
andallowSyntheticDefaultImports
tofalse
, making sure to use Namespace Imports for LFANs
- Set
- Create a file
tsconfig-cjs.json
, extending the primary one, and in it:- Set
verbatimModuleSyntax
tofalse
- Set
module
to"CommonJS"
- Set
- 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"
- Set
- In tsconfig:
- Set
module
to"ES2015"
- Set
target
to"ES2018"
(orES2015
if absolutely required) - Set
moduleResolution
to"Node"
- Set
lib
to the smallest required set of libraries - Set
outDir
todist/esm
- Set
sourceMap
totrue
- Set
allowSyntheticDefaultImports
totrue
- If you have any LFANs as dependencies, or may do in future, set
esModuleInterop
totrue
; otherwisefalse
- Set
- Create a file
tsconfig-cjs.json
, extending the primary one, and in it:- Set
verbatimModuleSyntax
tofalse
- Set
module
to"CommonJS"
- Set
outDir
todist/cjs
- Set
- Run
npx tsc -p ./tsconfig-cjs.json
to build the CJS target, andnpx 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
tonode-modules
- In package.json, set
type
to"module"
- In tsconfig:
- Set
module
andmoduleResolution
to"Node16"
- Set
target
to"ES2022"
- Set
verbatimModuleSyntax
totrue
- Set
allowSyntheticDefaultImports
totrue
- Set
noEmit
totrue
- Set
- 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
andmoduleResolution
to"Node16"
- Set
target
to"ES2022"
- Set
verbatimModuleSyntax
totrue
- Set
allowSyntheticDefaultImports
totrue
- Set
outDir
todist
- Set
sourceMap
totrue
- Set
- Use
npx tsc
to build your JavaScript before running your app as normal
Top comments (0)