Publishing a TypeScript library means shipping JavaScript that matches the module formats your users actually run, plus declarations TypeScript can interpret correctly. This guide builds a small ESM-only package, explains when to offer CommonJS as well, and shows how to test the packed package from a clean consumer project.
Key Takeaways
- Choose your supported module formats first. One ESM build is a valid choice; it is not automatically a CommonJS package.
- Use
package.jsonexportsto define the public entry point and map TypeScript declarations alongside runtime files. - Compile TypeScript before publishing, and explicitly allow the build output in the npm package.
- Test the tarball in a separate project. Testing only from the library directory can hide missing files and incorrect package metadata.
- If you publish both ESM and CommonJS, provide distinct runtime and declaration entry points, then test both consumer paths.
Choose the module formats you intend to support
A TypeScript library does not publish TypeScript source as a substitute for JavaScript. Consumers need usable runtime files, and TypeScript consumers also need declarations describing the public API.
Start by deciding which environments matter:
| Target | Typical runtime entry | What to consider |
|---|---|---|
| ESM only | .js within a package marked "type": "module", or .mjs | A good fit when your supported consumers use ESM. CommonJS require() consumers are not supported by this entry point. |
| CommonJS only | .js within a package marked "type": "commonjs", or .cjs | Appropriate when your target environment expects require(). ESM consumers may be able to import it, but that is not the same as publishing a native ESM build. |
| ESM and CommonJS | Separate ESM and CommonJS files selected with conditional exports | More compatibility work: runtime and declaration branches must agree, and both need testing. |
Node uses file extensions and the nearest package.json type field to determine how .js files are interpreted. TypeScript's module and moduleResolution settings affect how it models and emits modules; they do not make every build suitable for every runtime. In particular, moduleResolution: "bundler" permits some import patterns intended for bundlers that may not work when emitted JavaScript is run directly by Node.
This tutorial's runnable package targets Node.js ESM. If your users need other environments, adjust the build and test those targets rather than assuming this configuration is universal.
Build a small ESM-only package
The example exports one function and uses TypeScript's Node-aware module settings. It assumes a supported Node.js release and a current TypeScript compiler with NodeNext support.
Create this file structure:
tiny-labels/
├── package.json
├── tsconfig.json
└── src/
└── index.ts
Install TypeScript as a development dependency:
npm install --save-dev typescript
Add the implementation in src/index.ts:
export function formatLabel(value: string): string {
return value.trim().replace(/\s+/g, " ").toUpperCase();
}
Use a .js extension in relative imports between TypeScript files when writing code intended for Node's ESM resolution. TypeScript can resolve such specifiers to the corresponding TypeScript source during compilation, while the emitted JavaScript retains a Node-usable .js specifier.
Configure tsconfig.json to emit both JavaScript and declarations:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"strict": true
},
"include": ["src/**/*.ts"]
}
With the package marked as ESM, NodeNext treats these .ts files as ESM input. The build produces dist/index.js and dist/index.d.ts. The declaration file describes the API; it is not the runtime implementation.
Define the package entry points and publishing allowlist in package.json:
{
"name": "tiny-labels",
"version": "1.0.0",
"type": "module",
"files": [
"dist",
"README.md",
"LICENSE"
],
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"prepack": "npm run build"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}
Use the TypeScript version installed and supported by your project; the version range above is illustrative, not a claim about the latest release. Keep the version recorded by your package manager's lockfile for reproducible local builds.
Here, type marks .js files in the package as ESM. exports defines the package's public root import and associates its declaration file with that entry. Once exports is present, consumers generally cannot import arbitrary internal paths such as tiny-labels/dist/index.js unless you expose them. The top-level types field also helps tools that use the legacy package metadata convention. The files list limits what npm includes; make sure the compiled output is on it.
The prepack script builds before npm creates a package tarball. You can also build explicitly while developing:
npm run build
For an ESM package, a consumer can import the published API like this:
import { formatLabel } from "tiny-labels";
console.log(formatLabel(" hello npm "));
// HELLO NPM
This package does not promise that require("tiny-labels") works. Do not describe it as supporting CommonJS unless you publish and test a CommonJS entry point.
If you need both ESM and CommonJS
A dual-format package needs two runtime paths, not merely an exports object with two conditions pointing at the same file. For example, its package exports might conceptually look like this:
{
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
}
The ESM branch uses an ESM runtime file and an ESM declaration file; the CommonJS branch uses a CommonJS runtime file and a CommonJS declaration file. .mjs and .cjs make the runtime formats explicit. .d.mts and .d.cts make the declaration formats explicit for TypeScript's Node-aware resolution. If you instead use .js and .d.ts, their interpretation depends on the nearest package type field, so the output directory layout and package scopes must be designed accordingly.
A build tool or a carefully configured multi-build can generate both branches. Whichever approach you choose, inspect its actual output: confirm that the ESM file contains ESM-compatible output, the CommonJS file supports require(), the declaration files match their branches, and every path in exports exists in the packed archive. A single TypeScript configuration is not guaranteed to produce correct outputs for both targets.
Also consider whether the additional compatibility is worth maintaining. Dual packages can have separate module instances when consumers load the two branches in one process, which matters for packages that keep mutable singleton state. Test mixed usage if your library has such state; separate entry points do not make the two copies share it automatically.
Inspect and test the package tarball
First create a clean build and inspect what npm intends to publish:
npm run build
npm pack --dry-run
Confirm the listing contains dist/index.js and dist/index.d.ts, along with the intended README and license. If dist is absent, check the files allowlist, ignore files, and build output path. Do not rely on a successful build alone: npm publishes the package contents, not your working directory.
Then create a tarball and install it into a separate consumer directory. Run these commands from the library directory, with ../consumer available as a sibling directory:
mkdir -p ../consumer
npm pack --pack-destination ../consumer
cd ../consumer
npm init -y
npm install ./tiny-labels-1.0.0.tgz
Run a Node ESM smoke test from the consumer directory:
node --input-type=module -e 'import { formatLabel } from "tiny-labels"; console.log(formatLabel(" hello npm "))'
Expected output:
HELLO NPM
To check declaration resolution from a TypeScript consumer, create consumer.ts in that directory:
import { formatLabel } from "tiny-labels";
const label: string = formatLabel("TypeScript");
console.log(label);
Set the consumer package to ESM by adding this field to its package.json:
{
"type": "module"
}
Then run the compiler with Node-aware settings:
npx tsc --noEmit --strict --target ES2022 --module NodeNext --moduleResolution NodeNext consumer.ts
This checks the installed package's type entry point rather than a source file reached through a workspace path or editor alias. For a dual-format package, repeat the checks with an ESM consumer and a CommonJS consumer, including a TypeScript check for each declaration branch. A test of one branch does not validate the other.
Before publishing, review the final archive and metadata:
npm pack --dry-run
npm publish --access public
Use the access setting appropriate to your package and account. Publishing is a separate step from testing; inspect the package name, version, included files, and registry access before running it.
Common Mistakes
- Assuming TypeScript source makes a package runnable. Publish compiled JavaScript for the environments you support, and declarations for TypeScript consumers.
- Treating
moduleas a universal output switch. Module emission, resolution rules, file extensions, and package scope work together. Check the emitted files against the target runtime. - Declaring CommonJS support without a CommonJS entry. An ESM
index.jsdoes not become a reliablerequire()target just because consumers have TypeScript installed. - Pointing both conditional branches at one file. The file must actually match the selected runtime format; declarations must match the module interpretation too.
- Forgetting npm's file selection. A local
distdirectory is not proof thatdistis in the tarball. Inspectnpm pack --dry-runor install the resulting archive. - Testing through a workspace link only. Links can expose files and source paths that are missing from the published package. Test a packed archive in a clean project.
- Assuming
exportsallows deep imports. It defines the public paths. Add subpath exports deliberately if consumers need them.
Best Practices
- Write down the supported runtimes and module formats before choosing TypeScript settings.
- Prefer the simplest output that serves your audience. Publish ESM only if that is the support contract you can stand behind.
- Keep runtime files, declarations, and
exportsentries aligned. Use explicit extensions and package scopes where they clarify module interpretation. - Build before packing, and inspect the tarball as part of release checks.
- Test the installed tarball from a clean consumer project. Exercise every supported runtime and declaration branch.
- Check the official npm and TypeScript documentation for the versions you use. The live documentation was not available for verification while preparing this guide, so version-specific behavior and current CLI details should be confirmed before release.
Conclusion
Avoid module confusion by making the support contract explicit: choose ESM, CommonJS, or both; map each runtime and declaration entry accurately; and test the package users will actually install. A clean tarball test is the final check that your build, metadata, and published files agree.
References
- npm package.json documentation — package fields including
type,exports, andfiles. Confirm the documentation for the npm version you use. - TypeScript module reference — module settings and Node-aware module resolution.
- TypeScript: Publishing declaration files — declaration-file publishing guidance.
