Modules, packages and types
Almost nobody is taught how a package is put together. Most people learn it by copying a package.json from another project and hoping. What follows is what actually happens between an import line and the code that runs, and how to ship code that other people import.
Where modules came from
1.1 One page, one scope
JavaScript shipped in 1995 with no modules at all. A page loaded its code with <script> tags, and every script ran in the same global scope. A variable declared at the top of one file was visible to every other file, and could be overwritten by any of them.
<script src="slideshow.js"></script>
<!-- declares config -->
<script src="analytics.js"></script>
<!-- also declares config. The last one wins. -->
Nothing in the language could stop two files colliding, and nothing recorded that one file needed another. If analytics.js called a function from slideshow.js, the only thing that made it work was the order of the tags. The order of the <script> tags was the dependency system.
1.2 A function call as a module
Calling a function creates a new scope. Variables declared inside it are invisible outside, and they stay alive for as long as anything created inside still refers to them. That was enough to build modules out of:
var Slideshow = (function () {
var current = 0; // private: nothing outside can reach it
function next() {
current++;
}
return { next: next }; // public: one global name
})();
A function expression, called the moment it is defined. That is an IIFE, short for Immediately Invoked Function Expression. Its local variables are private, and what it returns is the public part. This shape is the module pattern, and through the 2000s it was how every serious library was written: claim exactly one global name, hide everything else.
jQuery, the library most websites used at the time to find and change elements on a page, claimed the name $. So did Prototype.js, another popular library of the same years. A page that loaded both had two libraries fighting over one name, so jQuery shipped jQuery.noConflict(), a function that hands $ back to whichever library had it before and leaves jQuery reachable only under its longer name:
var jq = jQuery.noConflict(); // $ belongs to Prototype.js again
jq("p").hide();
That function is the whole problem in miniature. Names were a shared, global resource, and libraries negotiated over them by politeness.
1.3 A server that could read files
In January 2009 a group of developers started a project, later named CommonJS, to give JavaScript what it needed outside the browser. Node.js, released the same year, adopted its module format:
const fs = require("fs");
function readConfig() {
/* ... */
}
module.exports = { readConfig };
require reads another file, runs it, and returns whatever that file put on module.exports. It is synchronous: the program stops until the file has been read and run. On a server, reading from a local disk, that takes microseconds.
npm followed in 2010: a public registry of packages, and the command-line tool that installs them. It made it normal for a module to come from somebody else entirely, and almost everything published to it for the next decade was CommonJS.
1.4 The browser could not wait
A synchronous require was useless in a browser. There the file comes over the network, which can take a second, and JavaScript in a page runs on a single thread. While it waits, nothing else happens. Every require would have frozen the page.
The answers came from libraries and tools, not from the language:
- AMD, short for Asynchronous Module Definition, popularised by RequireJS, a script loader. A module listed its dependencies up front and handed over a function to call once they had all arrived:
define(["dep"], function (dep) { … }). The loader fetched each dependency by adding a<script>tag to the page and waiting for the browser to report it loaded. It worked, and it was awkward to write. - Browserify (2011) took the opposite route. Keep writing
require, and run a build step that bundles every module into one file — all of them concatenated, plus a few lines of code that implementrequire— before anything reaches the browser. - webpack (2012) generalised bundling to everything a page needs: scripts, styles, images. Most of today's build tooling descends from it.
- UMD, short for Universal Module Definition, was a wrapper that let one file work as AMD, as CommonJS or as a plain global, by checking which of them was available. Older
distfolders still start with it:
(function (root, factory) {
if (typeof define === "function" && define.amd)
define([], factory); // AMD
else if (typeof module === "object")
module.exports = factory(); // CommonJS
else root.Slideshow = factory(); // a global, as before
})(this, function () {
return { next() {} };
});
By the mid-2010s, JavaScript had three ways to write a module and no standard one.
1.5 The language gets its own
ES2015 added import and export to the language itself. ES is short for ECMAScript, the official name of the standard that defines JavaScript, and ES2015 is its 2015 edition, also known as ES6.
The design differs from CommonJS in one deliberate way: ES modules are static. Imports and exports are declarations at the top level of a file, not function calls, so a tool can read what a file imports and exports without running a line of it.
Rollup, a bundler released the same year, used exactly that to leave out exported functions nothing imported. That is tree-shaking, removing code nothing uses, like shaking the dead leaves off a tree. It only works because the structure can be read before anything runs.
1.6 Five years to arrive
The syntax existed in 2015. Running it took much longer.
Browsers shipped <script type="module"> during 2017 and 2018. Node had a harder job: it had to run ES modules without breaking the hundreds of thousands of CommonJS packages already on npm. ES modules worked in Node without an experimental flag from late 2019, together with a new "type": "module" field in package.json that says which of the two systems a package's files use.
In between, almost everyone wrote import and export and let a compiler turn them back into require before the code ran — Babel, which translates newer JavaScript into older JavaScript, or TypeScript. A generation of developers learned ES module syntax with CommonJS behaviour underneath, and some habits from that time are still in tutorials.
In 2020, Vite, a development server and build tool, turned the arrangement round during development: instead of bundling everything first, it served each source file to the browser as a native ES module, at the moment the browser asked for it.
1.7 Where that leaves us
| Year | What happened |
|---|---|
| 1995 | JavaScript ships. No modules, one global scope |
| 2000s | The module pattern: one function call, one global name per library |
| 2009 | CommonJS and Node.js. require, synchronous, from disk |
| 2010 | npm |
| 2011–2012 | AMD for browsers. Browserify and webpack bundle require for the browser |
| 2015 | import and export in the language, static by design. Rollup and tree-shaking |
| 2017–2019 | Browsers, then Node, run ES modules. "type": "module" |
| 2020 | Vite serves ES modules natively during development |
| 2024 | Node can require() an ES module |
Two module systems, both in daily use. Of 16,231 widely used npm packages surveyed in June 2026, 8,378 shipped only CommonJS, 3,574 shipped both and 2,590 shipped only ES modules. Most of the code you install was written for require, and most of the code you write uses import.
CommonJS from the inside
2.1 Your file is a function body
arguments holds the arguments a function was called with, and it only exists inside a function. Run this as the whole of a file called args.cjs:
console.log(arguments.length); // ?
console.log(arguments[0] === exports, arguments[1] === require, arguments[2] === module); // ?
console.log(arguments[3] === __filename, arguments[4] === __dirname); // ?
Output
5
true true true
true true
The top level of a CommonJS file is inside a function, and that function was called with five arguments. Node will show you the function itself:
console.log(require("node:module").wrapper);
Output
[
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
]
Node reads your file as text, puts it between those two strings, and calls the result. Every CommonJS module is the module pattern, applied for you. Everything that looks built in at the top of a CommonJS file is a parameter of that function:
| Name | What it is |
|---|---|
require | a function Node passes in, which loads another module |
module | an object describing this module. Its exports property is what other files receive |
exports | a second name for module.exports, for convenience |
__filename, __dirname | strings: the path of this file, and of its folder |
Everything else about CommonJS follows from it being a function body:
- Top-level variables are local variables. A
constat the top of one file is invisible to every other file and never becomes a property of the global object. The collisions of one shared global scope are gone. returnworks at the top level. It ends the function, so the rest of the file doesn't run.- Top-level
thisismodule.exports, because that is what Node passes asthiswhen it calls the function.
2.2 Replacing exports does nothing
// a.cjs
exports = { name: "a" };
// b.cjs
module.exports = { name: "b" };
// main.cjs
console.log(require("./a.cjs"), require("./b.cjs")); // ?
Output
{} { name: 'b' }
exports is a parameter. Assigning to a parameter changes what that local name refers to, and nothing else. What Node hands to whoever called require is module.exports, which still refers to the empty object Node created at the start.
exports.name = "a" would have worked, because it changes the object that both names refer to. So the rule is: add properties to exports, or replace module.exports, but never assign to exports.
2.3 require is a function call
Because require is an ordinary function, it can be called anywhere, with anything:
if (process.env.NODE_ENV === "production") {
module.exports = require("./prod.js");
} else {
module.exports = require("./dev.js");
}
const name = "lodash";
const _ = require(name); // decided while the program runs
Which files this module loads, and what it ends up exporting, can only be known by running it. To find out what is in the bag, you have to empty it.
require also guesses. Given require("./util"), Node tries ./util.js, then ./util.json, then a folder ./util with an index.js inside, and loads the first that exists. Writing imports without an extension is a CommonJS habit, and every bundler kept it.
And require remembers. The first call for a file runs it. Every later call for the same file returns the same module.exports object without running the file again.
2.4 You get the object as it was
// counter.cjs
let count = 0;
function increment() {
count++;
}
module.exports = { count, increment };
// main.cjs
const counter = require("./counter.cjs");
console.log("before:", counter.count);
counter.increment();
console.log("after: ", counter.count); // ?
Output
before: 0
after: 0
{ count, increment } built a new object and copied the current value of count, zero, into it. increment changes the variable count inside counter.cjs, and nothing connects that variable to the object any more. What require returned is a plain object, holding whatever was put in it when that line ran.
To share a value that changes, a CommonJS module exports a function that reads it, or changes the property on module.exports itself.
2.5 Circular requires hand you half a module
Two files that require each other:
// a.cjs
const b = require("./b.cjs");
module.exports = { name: "a" };
console.log("b.partner() =", b.partner()); // ?
// b.cjs
const a = require("./a.cjs");
module.exports = { partner: () => a.name };
Run a.cjs.
Output
b.partner() = undefined
(node:…) Warning: Accessing non-existent property 'name' of module exports inside circular dependency
Step by step:
a.cjsstarts. Itsmodule.exportsis still the empty object Node created for it, and on its first line it requiresb.cjs.b.cjsrequiresa.cjs. That file is already loading, so instead of running it again, Node hands over itsmodule.exportsas it is at this moment: the empty object.b.cjsfinishes.a.cjscarries on and replaces itsmodule.exportswith a new object.b.cjsstill holds the old, empty one, and always will.
Node prints a warning, not an error, and the program carries on with a module that is permanently half there.
ES modules from the inside
3.1 Read the label, don't empty the bag
if (true) {
import { settings } from "./config.js";
}
Output
SyntaxError: Unexpected token '{'
Not an error while running. The file never starts. An import declaration can only appear at the top level of a module, and only with a literal string. That restriction is the point of the design: everything a module imports, and every name it exports, can be read from the file without running it. A CommonJS module has to be run to find out what is in it. An ES module carries a label.
When loading genuinely has to depend on something known only at run time, there is import(). It is an expression, not a declaration, so it can go anywhere, and it returns a promise for the module:
// config.js
console.log("config.js is being evaluated");
export const settings = { theme: "dark" };
// main.js
const wantsDark = true;
if (wantsDark) {
const { settings } = await import("./config.js");
console.log("loaded on demand:", settings.theme);
}
Output
config.js is being evaluated
loaded on demand: dark
import() works in CommonJS files too, and it is how a CommonJS program loads a package that ships only ES modules. A bundler can follow an import() whose argument is a literal string, and usually puts that module in a separate file that loads only when the line runs. If the string is computed, it has no way to know which file you mean.
3.2 Three phases
An ES module program is loaded in three phases, and none of your code runs until the third:
- Construction. Start from the entry file, read it, find its
importdeclarations and follow each one to the next file, until every file in the program is known and parsed. - Linking. Connect every import to the export it names, so that the two refer to the same variable.
- Evaluation. Run each module's code, every dependency before the module that imports it.
CommonJS has one phase: call the function.
3.3 Imports are live
Linking makes an import and the export it names into the same variable. The counter again, as ES modules:
// counter.js
export let count = 0;
export function increment() {
count++;
}
// main.js
import { count, increment } from "./counter.js";
console.log("before:", count);
increment();
console.log("after: ", count); // ?
Output
before: 0
after: 1
The CommonJS counter stayed at zero. This one moves. count in main.js is not a copy taken at import time. It is the variable inside counter.js, seen from outside.
It is a read-only view, though. count = 5 in main.js throws TypeError: Assignment to constant variable. Only the module that owns a variable can change it.
3.4 Evaluated once
// config.js
console.log("config.js is being evaluated");
export const settings = { theme: "dark" };
// a.js
import { settings } from "./config.js";
export function a() {
return settings;
}
// b.js
import { settings } from "./config.js";
export function b() {
return settings;
}
// main.js
import { a } from "./a.js";
import { b } from "./b.js";
console.log("same object?", a() === b()); // ?
Output
config.js is being evaluated
same object? true
Two files import config.js. Construction finds it twice and keeps one, so it runs once and both get the same settings object.
That makes a module-level variable a singleton for the whole program. Every store library you have used relies on this — a Pinia store, a Redux store, a React context object. The module that creates the store runs once, so everyone who imports it shares one instance. Keep that assumption in mind, because packaging can quietly break it.
3.5 Dependencies run first
// slow-setup.js
console.log("slow-setup: start");
await new Promise((r) => setTimeout(r, 100));
console.log("slow-setup: done");
export const ready = true;
// main.js
console.log("main: body runs");
import { ready } from "./slow-setup.js";
console.log("main: ready =", ready);
Predict the order of the four lines.
Output
slow-setup: start
slow-setup: done
main: body runs
main: ready = true
Two things are happening.
Imports are hoisted. main.js logs on its first line, but that line runs only after slow-setup.js has finished. Evaluation runs every dependency before the importing module's body starts, wherever the import line is written.
Top-level await makes everything that imports the module wait. slow-setup.js pauses for 100 ms, and main.js can't start until it is done. That is convenient for setup code, such as a configuration file that has to be fetched first, and costly in a module that many others import, because all of them wait.
CommonJS can't do this. A CommonJS module is a plain function, and require must have the result the moment the function returns. An ES module body is run more like an async function, which is what makes the await possible.
3.6 Circular imports fail loudly
The same kind of cycle, as ES modules:
// a.js
import { b } from "./b.js";
export const a = "A";
console.log("a sees", b);
// b.js
import { a } from "./a.js";
export const b = "B";
console.log("b sees", a); // ?
Run a.js.
Output
ReferenceError: Cannot access 'a' before initialization
Linking has already connected b.js's a to the variable in a.js. Evaluation runs b.js first, because it is a dependency of a.js, and at that moment the line export const a = "A" hasn't run yet. Reading a const before its line runs is an error anywhere in JavaScript — the variable exists but is in its temporal dead zone, the stretch between the start of its scope and the line that declares it.
Read it later, after both modules have finished, and the cycle works:
// a.js
import { b } from "./b.js";
export const a = "A";
console.log("a sees", b());
// b.js
import { a } from "./a.js";
export const b = () => "B, and a is " + a;
console.log("b loaded");
Output
b loaded
a sees B, and a is A
A cycle that is wrong throws at once and names the variable. In CommonJS the same mistake hands over half a module and carries on.
3.7 Side by side
| CommonJS | ES modules | |
|---|---|---|
| What happens | your file runs as a function | construction, linking, then evaluation |
| What a module needs | known only after it runs | readable before anything runs |
| What you import | an object, as it was when exported | a live, read-only view of the exporter's variable |
| Circular imports | half a module, silently | an error at the first early read, or they work |
Top-level this | module.exports | undefined |
| The file's location | __dirname, __filename — parameters | import.meta.dirname, import.meta.filename |
Top-level await | not allowed | allowed, and importers wait |
| Strict mode | only with "use strict" | always |
| Relative paths | the extension can be left out, and Node guesses | must name the file exactly |
Which file runs
4.1 Two imports you have written
In the base course's Express project, src/index.ts began like this:
import app from "./app.js";
There is no app.js in src. There is an app.ts.
In every React project built with Vite, you have written something like this:
import { Button } from "./Button";
No extension at all. Both work. One names a file that doesn't exist, and the other names no file.
4.2 Node does not guess
In an ES module, Node treats a relative specifier — the string after from — as a path, the way a browser treats a URL. It does not try extensions, and it does not look for an index.js. The path has to name a file that exists when the code runs. So the right way to write an import depends on what will run it:
- A bundler runs it. Vite processes your React code before the browser ever sees it, and like
require, it tries extensions../ButtonfindsButton.tsx. tsccompiles it, then Node runs the result. That was the Express project's build:tscturnedsrc/app.tsintodist/app.js, and then Node randist/index.js. At that point the file that exists isapp.js, so that is the name to write, even in a.tsfile. TypeScript knows to look atapp.tsfor the types.- Node runs the
.tsfile directly, which current versions of Node can do. Then the file that exists isapp.ts, and that is the name to write.
The same three imports, checked by tsc with Node's rules and run three ways:
| You write | tsc, Node's rules | node src/index.ts | tsx src/index.ts |
|---|---|---|---|
"./app" | error TS2835: needs an explicit file extension | ERR_MODULE_NOT_FOUND | runs |
"./app.js" | ok | ERR_MODULE_NOT_FOUND | runs |
"./app.ts" | error TS5097, unless allowImportingTsExtensions | runs | runs |
tsx, the command the Express project used during development, runs every variant, because it guesses like a bundler. That is why the development setup never complained. Only Node, running the compiled output, holds you to the rule.
Neither of the two imports was wrong. Each is correct for whoever runs the file.
4.3 How a package names its entry point
A specifier that doesn't start with . or /, such as "react", is a bare specifier: a package name. Node walks up the folders from the importing file, looking in each node_modules folder for a package with that name. Having found the package folder, it still has to choose one file inside it, and the package's package.json says which.
For most of npm's history it said so with a field called main:
{
"name": "greet",
"main": "lib/index.js"
}
require("greet") meant: find node_modules/greet, read main, load lib/index.js. If there was no main, Node loaded index.js. That was the entire rule. And every other file in the package was reachable by its path:
require("greet/lib/internal/format.js"); // a file the author never meant anyone to use
So a package's whole folder layout was public. Renaming an internal file could break somebody, and an author had no way to know who.
Other tools added fields of their own, each read by different software:
| Field | Read by | What it names |
|---|---|---|
main | Node, and every tool that imitates it | the file require("pkg") loads |
browser | bundlers building for a browser | a different file to use in browsers. Browserify started it |
module | bundlers only, never Node | an ES module version of the package |
types or typings | TypeScript | the package's type declarations |
module was a convention bundlers agreed on around 2016. Node never read it, so a package that offers ES modules only through module has ES modules that work in a bundler and not in Node.
4.4 The exports map
Since 2019, Node reads a field called exports, which replaces all of them for every tool that understands it:
{
"name": "async-kit",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
"." is the package itself — what import … from "async-kit" means. Inside it, conditions choose a file depending on how it was asked for: an import gets one file, a require gets another.
exports also does something main never did. Anything it doesn't list does not exist from outside the package:
try {
await import("async-kit/dist/index.js");
} catch (e) {
console.log(e.code); // ?
}
Output
ERR_PACKAGE_PATH_NOT_EXPORTED
The file is there in node_modules, and Node refuses to load it. That is the point. With exports, a package has a public surface — the entries it lists — and everything else is internal, free to be renamed or reorganised without breaking anyone.
Adding one can still break people. When axios, a widely used library for making HTTP requests, added an exports map in its 1.0 release in 2022, projects that had been importing its internal helper files by path stopped working, and asked for them back. Today some of them are listed again, under a name that says exactly what you are doing:
"./unsafe/*": "./lib/*"
A package can list more than one entry point, each with a path of its own:
"exports": {
".": "./dist/index.js",
"./retry": "./dist/retry.js",
"./package.json": "./package.json"
}
import { retry } from "async-kit/retry" now works, and nothing else under dist does. The ./package.json line is there because some tools read a package's package.json by importing it, and exports would block that too.
A wildcard such as "./*": "./dist/*.js" reopens the whole folder, which undoes what exports is for. axios did it on purpose, and named it unsafe.
4.5 Conditions are a list, in order
A condition object is checked from top to bottom, and the first condition the environment recognises wins.
| Condition | Matched by |
|---|---|
import / require | how the package was loaded |
types | TypeScript, looking for type declarations |
node / browser | bundlers, depending on the platform they build for. Node always matches node |
development / production | bundlers that support it, depending on the build mode |
default | everything. It belongs last |
Order matters because matching stops at the first hit. A default placed first catches everything below it.
Packages define their own conditions too. React has a separate build of itself for React Server Components, components that run only on the server and send their output to the browser:
"exports": {
".": {
"react-server": "./react.react-server.js",
"default": "./index.js"
}
}
The same import loads a different file depending on whether the environment turns that condition on. Node turns extra conditions on with --conditions:
// probe.js
import * as React from "react";
console.log("useState:", typeof React.useState);
console.log("file:", import.meta.resolve("react"));
node probe.js
node --conditions=react-server probe.js
Output
useState: function
file: file:///…/node_modules/react/index.js
useState: undefined
file: file:///…/node_modules/react/react.react-server.js
The server build has no useState. State that changes on a click means nothing on a server that renders once, so the server build of React leaves it out, and a server component that tries to use it fails. Frameworks that support server components turn the condition on in their bundler, for the server's code only.
jQuery 4 uses conditions to serve different builds to Node and to bundlers:
".": {
"node": {
"import": "./dist-module/wrappers/jquery.node-module-wrapper.js",
"default": "./dist/jquery.js"
},
"module": {
"import": "./dist-module/jquery.module.js",
"default": "./dist/wrappers/jquery.bundler-require-wrapper.js"
},
"import": "./dist-module/jquery.module.js",
"default": "./dist/jquery.js"
}
Conditions nest, and each level is checked top to bottom.
4.6 Ask instead of guessing
There is no need to work all of this out by hand. Ask the runtime:
console.log(import.meta.resolve("react"));
import.meta.resolve() returns the file an import of that specifier would load, without loading it. In CommonJS, require.resolve() does the same for require. From a terminal:
node --input-type=module -e 'console.log(import.meta.resolve("react"))'
When an import does something surprising, this is the first thing to run.
Two systems in one program
5.1 How Node decides which system a file uses
For every file it loads, Node decides whether it is CommonJS or an ES module:
- By the extension, if that settles it.
.mjsis always an ES module,.cjsalways CommonJS. For TypeScript,.mtsand.ctsmean the same. - Otherwise, by the nearest
package.jsonabove the file."type": "module"makes.jsfiles ES modules,"type": "commonjs"makes them CommonJS. - If there is no
"type"field at all, Node guesses. It tries the file as CommonJS, and if that fails because the file usesimportorexport, it parses it again as an ES module and warns:
Warning: Module type of file:///…/guess.js is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
The guess usually works, but it is a guess, and it costs a second parse of every such file. Set the field.
One field sets the default for the whole package, and an extension overrides it for a single file. That is how a package with "type": "module" can still ship one CommonJS file: name it .cjs.
TypeScript reads the same field to decide what each .ts file is. In a package without "type": "module", a .ts file is CommonJS, and with the commonly used verbatimModuleSyntax setting every export in it becomes an error:
error TS1287: A top-level 'export' modifier cannot be used on value declarations in a CommonJS module when 'verbatimModuleSyntax' is enabled.
The code didn't change. The missing field changed what TypeScript thinks the file is.
5.2 Importing CommonJS from an ES module
import lib from "./lib.cjs"; // always works
import { add } from "./lib.cjs"; // sometimes works
The default import is whatever the CommonJS file put on module.exports. That always works.
Named imports are harder. A CommonJS module has no label to read, so Node reads the file's source text without running it and looks for patterns that look like exports. Some shapes are recognised and some are not:
| The CommonJS file says | import { add } |
|---|---|
exports.add = function … | works |
module.exports = { add } | works |
module.exports = { add: function … } | works |
module.exports = { add: (a, b) => a + b } | not detected |
const api = {}, then api.add = …, then module.exports = api | not detected |
When a name isn't detected, the import fails with SyntaxError: Named export 'add' not found, for perfectly ordinary code. The way out is the import that always works:
import lib from "./lib.cjs";
lib.add(1, 2);
5.3 Requiring an ES module from CommonJS
For years this was the wall between the two systems: CommonJS could not require() an ES module at all. Since Node 22.12, it can:
// esm-shape.mjs
export const answer = 42;
export default function hello() {}
// shape.cjs
const m = require("./esm-shape.mjs");
console.log(Object.keys(m), "| typeof m.default:", typeof m.default); // ?
Output
[ '__esModule', 'answer', 'default' ] | typeof m.default: function
You get an object with every export by name, the default export under default, and an __esModule marker that tools use to recognise it.
There is one exception. require is synchronous, so it must have the finished module the moment it returns. A module that uses top-level await can't be finished synchronously:
// tla.mjs
await Promise.resolve();
export const ready = true;
// req-tla.cjs
try {
require("./tla.mjs");
} catch (e) {
console.log(e.code); // ?
}
Output
ERR_REQUIRE_ASYNC_MODULE
Keep top-level await out of anything a library exports, and CommonJS code can load it.
5.4 A default export, compiled to CommonJS
A library written with a default export:
export default function greet(name: string): string {
return `hello, ${name}`;
}
export const version = "1.0";
Built for both systems, the ES module consumer writes what you would expect. The CommonJS consumer doesn't:
const greet = require("greet");
console.log(typeof greet, typeof greet.default); // ?
Output
object function
A compiler turning export default into CommonJS has to put it somewhere on module.exports, and it puts it on a property called default. So CommonJS callers write require("greet").default("world"). Named exports read the same in both systems, const { greet } = require("greet") and import { greet } from "greet", which is one reason libraries that ship both prefer them.
5.5 Importing JSON
import data from "./data.json" with { type: "json" };
In an ES module, without the with { type: "json" } part, the import fails with ERR_IMPORT_ATTRIBUTE_MISSING.
The requirement comes from the browser. There, a module is fetched by URL, and the file extension means nothing: the server's Content-Type response header decides how a response is treated. If a URL ending in .json were answered with JavaScript — by a compromised server, or just a misconfigured one — an import that was meant to load some data would run code with full access to the page. With the attribute, the importing code states what it expects, and the response is refused unless it is JSON. JSON loaded that way is only ever parsed, never run.
Node requires the same attribute so that one line means the same thing everywhere. In CommonJS, require("./data.json") reads the file from disk and parses it, with no attribute.
5.6 Why packages still ship both
If CommonJS can now require() an ES module, why not publish ES modules only?
For new code that is a defensible choice, and the direction the ecosystem is going. But a library doesn't choose its consumers. Some run on older Node versions or test setups configured years ago, some sit behind tools that don't follow Node's newest behaviour, and some are large CommonJS applications whose owners will not migrate for the sake of one package. Half the widely used packages on npm are still CommonJS-only, and the long tail of npm is older still.
So the industry default for a library that should be widely usable is still to ship both: an ES module build for modern consumers and a CommonJS build for everyone else, both described by one exports map. That has a price.
How many copies
6.1 One package, two builds, two copies
async-kit is a small library. One of its functions, once, shares a single request between callers who ask for the same key at the same time, by keeping the requests in progress in a module-level Map:
const inFlight = new Map<string, Promise<unknown>>();
export function once<R>(key: string, fn: () => Promise<R>): Promise<R> {
let pending = inFlight.get(key) as Promise<R> | undefined;
if (!pending) {
pending = fn().finally(() => inFlight.delete(key));
inFlight.set(key, pending);
}
return pending;
}
The package ships an ES module build, dist/index.js, and a CommonJS build, dist/index.cjs, with the exports map shown earlier. Node treats them as two different modules, because they are two different files. A program can load both — your code imports the package, and some dependency deep in node_modules requires it:
import { createRequire } from "node:module";
import { once, sleep } from "async-kit";
// Somewhere else in the same program, a dependency that uses CommonJS:
const require = createRequire(import.meta.url);
const viaRequire = require("async-kit");
let calls = 0;
const loadUser = () => {
calls++;
return sleep(20).then(() => ({ name: "Ada" }));
};
await Promise.all([once("user:1", loadUser), viaRequire.once("user:1", loadUser)]);
console.log("two callers, same key — calls:", calls); // ?
Output
two callers, same key — calls: 2
once exists to make that number 1. It is 2, because there are two once functions with two inFlight maps, one in each build. Each was evaluated once, exactly as a module should be. There are simply two modules.
This is the dual package hazard. In real programs it is rarely as deliberate as this. Your code imports a package, a dependency requires it, and neither knows about the other.
6.2 When it matters
Two copies of a function are usually harmless. It matters when the module holds state or identity:
- Module-level state. A cache, a request map, a counter. Two copies of the module mean two of each, and nothing reports it.
instanceof. Every class exists twice. An error thrown by one copy is not aninstanceofthe other copy's class, so acatchthat checkserr instanceof TimeoutErrorquietly stops matching.- Registries and singletons. A plugin registry, a connection pool, a store: two copies, two of each.
6.3 The condition that fixes it
Node added a condition for exactly this, at the same time as require() of ES modules:
"exports": {
".": {
"module-sync": "./dist/index.js",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
module-sync is matched only by Node versions that can require() an ES module. There, both import and require land on the ES module file — the first condition wins for both — and there is one copy. Older runtimes don't recognise the condition, skip it, and fall back to the require build as before.
The same program, with "module-sync" first
two callers, same key — calls: 1
6.4 Two copies of React
You have very probably met this hazard without its name. React keeps track of which component is currently rendering in module-level state, which is how a hook knows which component it belongs to. Two copies of React in one application means two copies of that state, and a component rendered by one copy calls hooks from the other. React detects it and stops with "Invalid hook call", listing among the possible causes:
You might have more than one copy of React in the same app.
The usual cause is a library that listed React as an ordinary dependency, so npm installed a private copy for it next to yours. The fix, for the library's author, is to declare React as a peer dependency: "I need React, but use the one the application already has." npm then installs one shared copy.
npm ls react lists every copy installed, and which package brought each one in.
TypeScript's part
7.1 Running is not checking
// wrong.ts
const n: number = "definitely not a number";
console.log("ran anyway, n =", n);
node wrong.ts
ran anyway, n = definitely not a number
Current versions of Node run TypeScript files directly. It is tempting to conclude that TypeScript is built into Node. It isn't. Node strips the type annotations, replacing them with spaces, and runs what is left. It never looks at them. tsx, the command the Express project used during development, does the same: strip and run, check nothing.
Three consequences:
- Only erasable syntax works. An
enumproduces real JavaScript, so there is nothing to simply erase, and Node stops withERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. Constructor parameter properties,constructor(public x: number), fail the same way. - Not inside
node_modules. Node refuses to strip types from an installed package, withERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. A library has to ship JavaScript. - Checking is still the TypeScript compiler's job.
tsc --noEmitchecks every file and writes nothing.
7.2 Three jobs
tsc, the TypeScript compiler, can do three separate jobs:
- Check the code for type errors.
- Write JavaScript: the code with the types removed, and newer syntax translated for older runtimes if you ask.
- Write declaration files:
.d.tsfiles containing only the types of what a module exports, which is how someone who installs your package gets autocompletion and type errors without your source.
Each project chooses which of the three tsc does, and which some other tool does instead:
| Project | Checks | Writes JavaScript | Writes declarations |
|---|---|---|---|
| A Vite application | tsc | Vite | nobody needs them |
| The Express project | tsc | tsc, into dist | nobody needs them |
| A library built with Vite | tsc | Vite | tsc, or a Vite plugin |
The tsconfig.json file is where that choice is written down.
7.3 What a tsconfig.json decides
A configuration file has several dozen possible settings. These are the ones that decide how a project behaves, grouped by the question each one answers.
What does the code run on?
| Setting | What it decides |
|---|---|
target | which version of JavaScript the output may use. Newer syntax than this is translated into older syntax |
lib | which built-in APIs exist. ES2023 is the language's own, DOM is the browser's. Without DOM, document is an error. Left out, it follows target and includes DOM |
types | which installed @types packages may add global names. [] means none |
How are imports found?
| Setting | What it decides |
|---|---|
module | which module system the code is written for, and who will run it. It also picks moduleResolution unless you set that yourself |
moduleResolution | the rules for finding the file an import names: Node's rules, or a bundler's |
What gets written?
| Setting | What it decides |
|---|---|
noEmit | check only, write nothing |
outDir, rootDir | where output goes, and which folder of source it mirrors |
declaration | also write .d.ts files |
emitDeclarationOnly | write only .d.ts files, because another tool writes the JavaScript |
sourceMap, declarationMap | write maps that lead a debugger, or an editor's "go to definition", from the output back to your source |
How strict is the checking?
| Setting | What it decides |
|---|---|
strict | a group of stricter checks, including no untyped parameters and careful null handling. On by default in current TypeScript |
noUncheckedIndexedAccess | items[i] may be undefined, so the code has to handle that |
exactOptionalPropertyTypes | an optional property that is missing and one set to undefined are different things |
skipLibCheck | don't check .d.ts files in node_modules. Faster, and it hides errors inside packages |
Can other tools handle each file?
| Setting | What it decides |
|---|---|
isolatedModules | every file must make sense on its own, because tools such as Vite compile one file at a time without seeing the rest |
verbatimModuleSyntax | imports and exports stay exactly as written, except those marked type, which are removed. So a file's module system is what its syntax says |
erasableSyntaxOnly | only syntax that can simply be deleted, so that anything that strips types, Node included, can run the file |
tsc --init writes a starting tsconfig.json for a new project:
{
"compilerOptions": {
"module": "nodenext",
"target": "esnext",
"types": [],
"sourceMap": true,
"declaration": true,
"declarationMap": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"strict": true,
"jsx": "react-jsx",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true
}
}
Every line is in the tables above, apart from three. jsx says how to compile JSX, for React components. noUncheckedSideEffectImports reports a bare import "./x.css" whose file doesn't exist, which TypeScript otherwise ignores. moduleDetection: "force" treats every file as a module, even one with no import or export, so that its top-level names stay local to it.
7.4 One folder, two programs
A new React project from Vite's template has three TypeScript configuration files, and most people never open them. tsconfig.json contains almost nothing:
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
The folder holds two programs that run in different places, and each needs different settings:
tsconfig.app.json | tsconfig.node.json | |
|---|---|---|
| Checks | src, the application | vite.config.ts |
| Runs in | a browser, after Vite has processed it | Node, when you run vite |
lib | ["ES2023", "DOM"] | ["ES2023"] — no document here |
types | ["vite/client"], for Vite's additions | ["node"], for Node's process and friends |
| How imports are found | moduleResolution: "bundler" | module: "nodenext" |
| Writes | nothing: noEmit | nothing: noEmit |
references ties them together, and the template's build script uses both tools for what each is good at:
"build": "tsc -b && vite build"
tsc -b checks both programs and writes nothing. vite build writes the JavaScript. If the check fails, the build stops before Vite starts.
Both files also set erasableSyntaxOnly. Vite strips types the same way Node does, so the template forbids anything that can't simply be stripped.
7.5 module has to describe who runs the code
TypeScript never runs your code, but to check import { x } from "y" it has to find the file that will run, and its types. So it imitates the rules of whatever will run the code, and module (or moduleResolution) says which rules to imitate:
nodenext | bundler, used by module: "preserve" or "esnext" | |
|---|---|---|
| Imitates | Node | Vite, webpack and other bundlers |
"./app" | error: Node needs the extension | fine: bundlers try extensions |
| Which file is CommonJS | decided by extension and "type", as Node does | not distinguished |
exports conditions it uses | types, node, and import or require depending on the importing file | types and import, always |
node16, node18 and node20 are the same rules frozen at older Node versions, which matters mostly for what they assume a CommonJS file may load.
You can watch the choice being made. tsc --traceResolution prints every step TypeScript takes to resolve each import, including lines like these:
Resolving in ESM mode with conditions 'import', 'types', 'node'.
Resolving in CJS mode with conditions 'require', 'types', 'node'.
The first is a file in an ES module importing a package, the second a CommonJS file importing the same package under nodenext. Same package, different conditions, possibly different files.
When the setting doesn't match whoever really runs the code, TypeScript checks against the wrong rules, and it fails in one of two directions:
bundlerfor code that Node runs. TypeScript acceptsimport { app } from "./app". Node crashes on that line withERR_MODULE_NOT_FOUND. The checker passed code that can't run.nodenextfor code that Vite builds. TypeScript rejectsimport { Button } from "./Button", which Vite would have handled fine. The checker fails code that works.
For a library, nodenext is the right choice even if a bundler builds it, because people may run it in Node, and code that satisfies Node's rules works in bundlers too.
7.6 Where types come from
When you import a package, TypeScript looks for its types in one of three places:
- In the package itself. Declaration files shipped alongside its JavaScript, named by a
typescondition inexports, or by the older top-leveltypesfield. - In a separate
@typespackage, written by somebody else and published under the@typesname on npm by DefinitelyTyped, a community project that writes type declarations for JavaScript libraries. - Nowhere. Then TypeScript reports an error, or treats everything from the package as
any, depending on your settings.
The first is normal for new packages. The second is how the JavaScript world got types at all. TypeScript appeared in 2012, and the libraries everyone used were already written in plain JavaScript, by authors with no reason to write declarations for a language they didn't use, so the community wrote them separately.
jQuery still works this way:
npm install jquery # the library: JavaScript only
npm install -D @types/jquery # its types, written by other people
The arrangement has a cost. jQuery 4.0 was published on 17 January 2026, removing a number of long-deprecated functions, $.trim among them. @types/jquery for version 4 followed on 23 February. For five weeks, the types anyone installed described jQuery 3, so $.trim(" hi ") type-checked without complaint and failed at runtime, because the function no longer existed. Separate types are separate software, released on someone else's schedule. React works the same way: the react package contains no declarations, and every React project installs @types/react beside it.
Installing an @types package no longer means TypeScript loads all of it. jQuery is mostly used through a global $:
// global.ts
const count: number = $("p").length;
global.ts(1,23): error TS2592: Cannot find name '$'. Do you need to install type definitions for jQuery? Try `npm i --save-dev @types/jquery` and then add 'jquery' to the types field in your tsconfig.
The package is installed, and TypeScript says $ doesn't exist. Global names from @types packages are loaded only for the packages listed in types, and the default list is empty. Two ways to fix it:
- Import it.
import $ from "jquery"always finds@types/jquery, whatevertypessays. - List it.
"types": ["jquery"]makes its global names available everywhere.
There is a trap between the two. Import jQuery in one file, and the global $ stops being an error in every other file too, because the import loaded the whole declaration package, globals included. Remove that one import later, and errors appear in files you never touched. If your code uses a global, list it in types.
The same applies to Node's own globals, such as process and Buffer. They come from @types/node and need "types": ["node"], which is exactly what Vite's tsconfig.node.json says.
7.7 TypeScript or JSDoc
A JavaScript file can carry full type information in comments, written in a format called JSDoc:
/**
* @typedef {{ amount: number, unit: string }} Quantity
*/
/**
* Scale a quantity by a factor.
* @param {Quantity} q
* @param {number} factor
* @returns {Quantity}
*/
export function scale(q, factor) {
return { amount: q.amount * factor, unit: q.unit };
}
scale({ amount: 2, unit: "cup" }, "double");
The TypeScript compiler checks it, with no TypeScript syntax anywhere in the file:
tsc --allowJs --checkJs --noEmit scale.js
Output
scale.js(15,35): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
Run that in a folder without a tsconfig.json. Naming files on the command line next to one is an error, TS5112, because tsc would otherwise ignore the configuration without telling you.
The same compiler writes declarations from it, so the people who install the package get ordinary types:
export type Quantity = {
amount: number;
unit: string;
};
export declare function scale(q: Quantity, factor: number): Quantity;
And the file itself runs unchanged, because it is JavaScript.
That makes the choice between the two a real one, and well-known projects have made it both ways:
| TypeScript | JavaScript with JSDoc | |
|---|---|---|
| What you write | .ts | .js, with types in comments |
| What ships | JavaScript a tool produced from it | the file you wrote |
| A build step for the JavaScript | needed | none |
| Debugging an installed copy | through source maps, if they were shipped | directly in the source |
| Complicated generic types | comfortable | verbose, sometimes clumsy |
In 2023 the team behind Svelte, a framework for building user interfaces in the same space as React and Vue, converted its own source from TypeScript to JavaScript with JSDoc, on the argument that for a library, a build step is a cost paid by its maintainers on every change. Look inside an installed copy of Svelte today: hundreds of .js files and no .ts, with exports pointing straight at the source. The file you would step through in a debugger is the file its authors wrote.
Most libraries use TypeScript. JSDoc is worth knowing because you will meet it, in older codebases, in configuration files and in single JavaScript files inside TypeScript projects — and because "we use TypeScript because everyone does" is a weaker reason than either side's real argument.
Declaration files have a module format
8.1 One declaration file, two builds
A .d.ts file is read by the same rules as a .js file: its extension and the nearest "type" field decide whether it describes an ES module or CommonJS. A package that ships both builds very often ships only one declaration file, and that file can describe only one of the two builds correctly. Which one depends on which way the package faces, and packages have faced both ways.
Facing ES modules. A package with "type": "module": the ES module build is index.js, the CommonJS build is index.cjs, and one index.d.ts describes both:
{
"name": "async-kit",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
Because of "type": "module", index.d.ts is read as an ES module. A CommonJS project whose tsconfig.json says module: "node16" imports it:
import { mapLimit } from "async-kit";
Output
use.ts(1,26): error TS1479: The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module and cannot be imported with 'require'. Consider writing a dynamic 'import("async-kit")' call instead.
To convert this file to an ECMAScript module, change its file extension to '.mts' or create a local package.json file with `{ "type": "module" }`.
At runtime nothing is wrong. require loads index.cjs, exactly as intended. But the declaration file claims the package is an ES module, and node16 imitates a Node that couldn't require() one. The types reject code that works.
8.2 The mirror image
Facing CommonJS. For most of the dual-package years, packages kept CommonJS as the default — no "type" field — and added the ES module build as a .mjs file. greet, the library with a default export, built that way:
{
"name": "greet",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
}
}
An ES module project uses it:
// greet.ts
import greet, { version } from "greet";
console.log(greet("world"), version);
node greet.ts, then tsc with module: "nodenext"
hello, world 1.0
greet.ts(2,13): error TS2349: This expression is not callable.
Type 'typeof import(".../node_modules/greet/dist/index")' has no call signatures.
The code runs, and TypeScript rejects it. With no "type" field, index.d.ts is a CommonJS declaration file, and when an ES module imports CommonJS, the default import is the whole module.exports object. So TypeScript concludes that greet is the module, which can't be called. At runtime, the import condition loaded index.mjs, where the default export really is the function.
The same mistake as before, in the opposite direction: an ES module consumer handed CommonJS types.
8.3 Who each layout breaks for
Both packages, imported by every kind of consumer:
| Consumer | Facing CommonJS, one .d.ts | Facing ES modules, one .d.ts | One declaration file per format |
|---|---|---|---|
ES module, node16 … nodenext | default imports: TS2349 | ok | ok |
ES module, bundler | ok | ok | ok |
CommonJS, node16 / node18 | ok | TS1479 | ok |
CommonJS, node20 / nodenext | ok | ok | ok |
Two things stand out.
The move to facing ES modules made things better. The old layout breaks ES module consumers under every one of Node's settings, the newest included. The new one breaks only CommonJS consumers on two older settings, which is why so many packages ship a single .d.ts and rarely hear about it.
And a bundler consumer never notices either problem, because the bundler rules don't distinguish CommonJS from ES modules at all.
8.4 One declaration file per format
The fix is a second declaration file, with an extension that states its format, and types nested under each condition so each build gets its own:
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/cjs/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
.d.cts is to .d.ts what .cjs is to .js: whatever the "type" field says, it describes CommonJS. In a package facing the other way, the fix is a .d.mts for the ES module build. Every row of the table passes.
This is what well-maintained dual packages do. date-fns, a library of date functions, and the core of TanStack Query, the data-fetching library many React projects use, both have exactly the exports above: nested types, with a .d.cts under require. axios ships an index.d.cts beside its index.d.ts and points require at it. TypeScript added both extensions in 2022 for exactly this, so they are not exotic, only younger than most tutorials.
Older projects used one more setting, moduleResolution: "node", sometimes written "node10". It imitated Node from before exports existed: it read only the top-level main and types fields, and never noticed any of the problems above. TypeScript 7 removed it. Tools that check packages still test it, though, which is the reason packages keep a top-level types field next to exports.
What modules cost
9.1 Measured: one import from a barrel
A barrel is a file, usually index.js, that re-exports everything in a folder, so that other files can write one tidy import:
// components/index.js
export { Button } from "./Button.js";
export { Card } from "./Card.js";
// … one line per component
// somewhere else
import { Button } from "./components/index.js";
Four hundred components, importing exactly one of them, each run in a fresh Node process:
via the barrel 400 modules evaluated 42 ms
direct import 1 modules evaluated 3 ms
A module is evaluated when anything imports it. Importing one name from the barrel imports the barrel, and the barrel imports all four hundred files, so all four hundred run, to deliver one function.
Reproduce it yourself
node barrel-cost.mjs
It generates the four hundred modules and the barrel in a temporary folder, then imports one component two ways. Each module increments a counter when it is evaluated, which is where the first column comes from. Each run starts a new Node process, so nothing is cached between them.
The milliseconds depend on your machine. The 400 and the 1 don't.
This cost lands wherever modules are loaded one by one. In Node — servers, tests, scripts — every import is real: a test file that imports one helper through a barrel loads the whole folder, on every test run. Vite's development server serves your source files individually too, so a barrel import there is hundreds of separate requests.
A production build is the one place where it can disappear.
9.2 Measured: one function from a CommonJS library
lodash is a large library of small utility functions. It ships as CommonJS. Its authors also publish the same code as ES modules, as a second package called lodash-es. A production build with Vite of a page that uses one function, debounce:
import { debounce } from "lodash"; // or "lodash-es"
window.addEventListener(
"input",
debounce(() => console.log("typed"), 200)
);
import { debounce } from "lodash" 71.94 kB
import { debounce } from "lodash-es" 2.93 kB
import debounce from "lodash/debounce" 4.00 kB
The same function, twenty-five times the size. From lodash-es, the bundler can read which exports are imported and leave out the hundreds of others: tree-shaking. From CommonJS lodash, it can't. What a CommonJS module exports is known only by running it, so the bundler has to keep the whole library. The third line reaches into lodash for the single file that holds debounce, which helps, but still drags in that file's own CommonJS dependencies.
Measure it yourself
In an empty folder:
npm init -y
npm install -D vite
npm install lodash lodash-es
Then an index.html containing only <script type="module" src="./main.js"></script>, a main.js with the two lines above, and:
npx vite build
Vite prints the size of each file it writes. Change the import and build again.
The code has to use debounce. In a first attempt that only exported a function calling it, the bundle came out at 0.67 kB, because nothing imported that function either, and the bundler removed all of it. That is tree-shaking working, not a measurement.
9.3 What a bundler may leave out
A bundler can only remove a module if removing it changes nothing. A module that does something the moment it is evaluated — changes a global, registers a plugin, adds a polyfill, which is code that supplies a built-in feature older environments lack — has a side effect, and leaving it out would change the program.
A package with a barrel, where one file has a side effect:
// node_modules/ui-kit/index.js
export { formatPrice } from "./format.js";
export { Chart } from "./chart.js";
// node_modules/ui-kit/chart.js
globalThis.uiKitCharts = new Map(); // runs the moment this file is evaluated
export class Chart {
draw() {
return "a chart";
}
}
An application imports only formatPrice, and Vite builds it:
ui-kit's package.json | What survives from chart.js |
|---|---|
no sideEffects field | the globalThis.uiKitCharts = new Map() line, but not the class |
"sideEffects": false | nothing |
Without the field, the bundler drops the unused class, but has to keep the line that runs on load, because it can't know whether anything depends on uiKitCharts. With "sideEffects": false, the package promises that no file does anything merely by being evaluated, so any file whose exports are unused can go entirely.
That is a promise, and a wrong one deletes code that was needed. If Chart registered itself somewhere that the rest of the package relied on, the application would now break, in production only. The field can also be a list of the files that do have side effects, but bundlers don't all match that list the same way, so whatever you write, build a small application against your package and look at what survived.
9.4 A library is not an application
An application is the last step: what it bundles is what users download. A library is an ingredient in somebody else's application, and their bundler will process it again. That changes several defaults:
- Don't bundle your dependencies. If your library uses another package, list it in
dependenciesand keep it out of your build, so the application's package manager can install one shared copy. Bundled in, every library ships its own. - Don't bundle a framework at all. Declare it a peer dependency, as with React: two copies of a framework is the hazard from before, at its worst.
- Don't minify. The application's build will minify everything once, at the end. A library minified in advance saves nobody anything, and makes every stack trace and debugging session that passes through it unreadable.
- Do ship source maps if your build changes the code much. They cost bytes in the package and nothing at runtime.
Vite's library mode, which builds libraries, minifies by default. Its output for one short function:
function e(e) {
return `hello, ${e}`;
}
That is greet, with every name replaced by a single letter. Anyone debugging an application that uses it steps into that.
Building a library
10.1 package.json, field by field
A dual package with all of it applied:
{
"name": "async-kit",
"version": "0.1.0",
"type": "module",
"exports": {
".": {
"module-sync": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/cjs/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./package.json": "./package.json"
},
"main": "./dist/index.cjs",
"types": "./dist/index.d.ts",
"files": ["dist"],
"sideEffects": false,
"scripts": {
"build": "vite build",
"typecheck": "tsc --noEmit"
}
}
| Field | Why it is there |
|---|---|
type | .js files are ES modules. The CommonJS build is named .cjs |
exports → module-sync | one copy of the code wherever Node can require() an ES module |
exports → import, require | the right build for each kind of consumer |
nested types | each build gets declarations in its own format |
./package.json | tools that read the package's package.json by importing it |
main, types | read only by tools that predate exports, and ignored by everything else |
files | only dist is published: not your source, your configuration or an old packed archive |
sideEffects | lets bundlers drop the parts of your package an application doesn't use. True for this package, since no file does anything on load |
Publishing adds more fields — the version policy, the Node versions you support, peer dependencies — but none of them change what an import gets.
10.2 Vite's library mode
One command, vite build, can produce all four files in dist:
// vite.config.ts
import { defineConfig } from "vite";
import dts from "unplugin-dts/vite";
export default defineConfig({
build: {
minify: false,
lib: {
entry: "src/index.ts",
formats: ["es", "cjs"],
fileName: "index",
},
},
plugins: [
dts({
outDirs: [
"dist", // index.d.ts, for the ES module build
{ dir: "dist/cjs", moduleFormat: "cjs" }, // index.d.cts, for the CommonJS build
],
}),
],
});
libswitches Vite from building an application to building a library.formatsasks for both builds. With"type": "module"inpackage.json, Vite names themindex.jsandindex.cjs, and without it,index.mjsandindex.js.minify: false, because a library shouldn't be minified, and library mode does it unless told otherwise.- The plugin writes the declaration files. Vite itself only strips types, like Node, and checks nothing.
unplugin-dtsis a plugin that runs the TypeScript compiler during the build to produce.d.tsfiles, andmoduleFormat: "cjs"makes it write a.d.ctscopy for the CommonJS build. Most tutorials show the same plugin under its older name,vite-plugin-dts, which is now a thin wrapper around it.
A library that depends on other packages also lists them in build.rolldownOptions.external, so they are imported at runtime instead of being copied into your build. Before Vite 8 the setting was called rollupOptions, which is the name most tutorials use, because Vite 8 switched its bundler to Rolldown, a newer bundler compatible with Rollup.
A catch with TypeScript 7. TypeScript 7 is a rewrite of the compiler in another language, Go, about ten times faster, run as tsc exactly as before. It doesn't yet offer the programmatic interface that tools like this plugin use to drive the compiler from inside a build. So the plugin also needs TypeScript 6, published for exactly this as a separate package:
npm install -D @typescript/typescript6
Without it, the build stops with a message saying so. With it, there is a side effect worth knowing about: that package brings a complete TypeScript 6 with its own tsc command, and depending on the order things were installed in, npx tsc may now run version 6 instead of 7. npx tsc --version tells you which. For checking your code the two report the same errors, but it explains a version number that otherwise makes no sense. You will meet TypeScript 6 elsewhere for a while too: Vite's own React template still installs it.
10.3 The declarations come from a second program
Vite writes the JavaScript. A separate run of the TypeScript compiler writes the declarations. Both start from the same source, so they normally describe the same exports — but nothing checks that they agree, and anything that changes the code on one path and not the other makes the types lie:
- Path aliases. Write
import { x } from "@/utils", and Vite resolves the alias in the JavaScript. A declaration file written without the same configuration keeps"@/utils", which means nothing inside somebody else'snode_modules. - Different entry points. Vite builds from
build.lib.entry, the compiler from whateverincludecovers. Declarations for files that aren't in the build, or a build entry with no declarations, are both possible. - Old files. A declaration file left in
distfrom an earlier build, for a function you've since removed. - Order. Vite empties
distbefore each build. Declarations written by a separate command beforevite buildare deleted by it.
Running the declaration step as a Vite plugin removes some of these, because it runs inside the same build, with the same configuration, after the folder has been emptied. It doesn't remove the underlying fact that there are two programs. The only thing that proves the package is consistent is checking the package itself.
The same result is possible without any bundler, with tsc alone: run it twice with two configuration files, once with module: "nodenext" into dist/esm and once with module: "commonjs" into dist/cjs, and put a one-line package.json containing { "type": "commonjs" } into dist/cjs. That file makes both the .js and the .d.ts files beside it CommonJS, for Node and for TypeScript alike, without renaming anything.
10.4 Checking before you publish
Three checks, each catching mistakes the others miss.
npm pack --dry-run lists exactly what npm publish would put in the package:
dist/cjs/index.d.cts
dist/index.cjs
dist/index.d.ts
dist/index.js
package.json
total files: 5
Without "files" it would also include your source, your configuration files, and any archive left over from the last npm pack, packed inside the new one. And if your .gitignore lists dist/, as almost every project's does, npm without "files" uses .gitignore to decide what to skip, and publishes a package with no build in it at all. With "files": ["dist"], the list wins.
Install the packed tarball into a separate project. npm pack writes async-kit-0.1.0.tgz, a tarball — the compressed archive that is exactly what the registry would receive. npm install ../async-kit/async-kit-0.1.0.tgz in another folder, then import it as an ES module, require it from CommonJS, and type-check against it. That tests the package as a stranger receives it, not the folder you built it in.
Ask a tool that knows every consumer. Are the Types Wrong is a tool that resolves a package the way TypeScript would under every setting, and reports where the declarations and the JavaScript disagree:
npx @arethetypeswrong/cli --pack .
For the package with one shared declaration file, its report:
│ node10 │ 💀 Resolution failed │
│ node16 (from CJS) │ 👺 Masquerading as ESM │
│ node16 (from ESM) │ 🟢 (ESM) │
│ bundler │ 🟢 │
"Masquerading as ESM" — ESM being the tool's abbreviation for ES modules — is the CommonJS consumer's error from earlier, found without writing a consumer. The node10 row is the removed setting, failing because that version of the package had no top-level main and types. With the per-format declarations and those two fields, as in the package.json above, the report is:
No problems found 🌟
If you decide not to care about node10, --profile node16 leaves it out. publint, a second checker, looks at package.json itself for common mistakes, such as an exports entry pointing at a file that doesn't exist.
When an import misbehaves
Most packaging bugs are one of a few questions, and each has a command that answers it directly:
| Question | Command | What it shows |
|---|---|---|
| Which file will this import load? | import.meta.resolve("pkg"), or require.resolve("pkg") in CommonJS | the exact file, without loading it |
| Why does TypeScript disagree with the runtime? | tsc --traceResolution | each step of TypeScript's search, the conditions it used and the file it chose |
| Is more than one copy installed? | npm ls react | every installed copy of a package, and what brought each one in |
| What will the package contain? | npm pack --dry-run | the exact list of published files |
| Are the types right for every consumer? | npx @arethetypeswrong/cli --pack . | the declaration file each kind of consumer gets, and whether it matches the JavaScript |
tsc --traceResolution prints a great deal. Search its output for the package name.
Take these 5 things from here
- CommonJS runs your file as a function and hands over whatever it put on
module.exports. ES modules are read, linked and then run, which is why their imports are live and their cycles fail loudly. - Node never guesses an extension in an ES module. Write the name of the file that will exist when the code runs, and set TypeScript's
moduleto match whoever runs it. exportsis a package's public surface. Conditions choose a file for each kind of consumer, in order, and anything not listed doesn't exist.- A dual package is two copies of your code. That's harmless until the module holds state or identity. Then use
module-sync. - Running TypeScript isn't checking it, and a declaration file has a module format.
tsc --noEmitchecks. A package with two builds needs a declaration file for each.
You should now be able to
- Explain why JavaScript has two module systems, and what problem each was designed to solve.
- Explain what Node does with a CommonJS file before running it, and use that to predict top-level
this,returnand an assignment toexports. - Predict what an importer sees when a module changes an exported value, in CommonJS and in ES modules.
- Say what a circular import does in each system, and why one fails silently and the other loudly.
- Predict when a module is evaluated, how many times, and in what order relative to the module that imports it, including with top-level
await. - Choose the extension for a relative import from what will run the code: a bundler,
tscand then Node, or Node directly. - Explain what
maindid, and write anexportsmap with conditions and subpaths, saying why order matters and what it hides. - Find out which file a specifier resolves to, at runtime and in TypeScript.
- Import CommonJS from an ES module and
requirean ES module from CommonJS, and say when each fails. - Recognise the dual package hazard, say when it matters, and fix it with
module-sync. - Read a
tsconfig.jsonand say what each important setting decides, including why Vite's template has three of them. - Choose
nodenextorbundlerfor a project, and say what goes wrong with the other. - Say where a package's types come from, and fix a global from an
@typespackage that TypeScript can't find. - Argue for TypeScript or for JSDoc in a library, with a reason other than habit.
- Say which consumers a package with one declaration file breaks for, and give it one per format.
- Explain why a barrel, a CommonJS dependency or a missing
sideEffectsfield makes code bigger or slower, and measure it. - Build a library in both formats with Vite, check what it would publish, and check its types for every consumer.
Reading
Required
- [Andres Käver's base course lecture on objects, inheritance and modules](https://courses.taltech.akaver.com/javascript/lectures/js-lang uage-3) — the modules part
- Node.js: Modules — Packages —
type,exports, conditions and the dual package hazard, from the source - TypeScript: Modules — Theory — why TypeScript resolves modules the way it does
- Vite: Library mode
Recommended
- Node.js: Modules — CommonJS — including the module wrapper, the module cache, cycles, and
require()of ES modules - Node.js: Modules — ECMAScript modules
- Node.js: Running TypeScript natively — exactly what type stripping supports
- TypeScript: TSConfig reference — every setting, with examples
- Are the Types Wrong? — paste in any package name and see how its types resolve for each kind of consumer. Its problem descriptions are a short course in packaging mistakes
- npm-esm-vs-cjs — the survey of how widely used packages ship, updated twice a year
- DevClass, "TypeScript is 'not worth it' for developing libraries, says Svelte author" — the JSDoc argument, and the reactions to it
Demos
- Barrel cost — how many modules run when you import one name through a barrel
- Practicum starter kit