TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

Publish a TypeScript Library to npm Without Module Confusion

Practical insights, clear explanations, and useful ideas for understanding the web.

October 26, 20269 min read6 topics
Publish a TypeScript Library to npm Without Module Confusion
Explore the details
#TypeScript#npm#Node.js#ESM#CommonJS#package publishing
October 26, 20269 min read

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.json exports to 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:

TargetTypical runtime entryWhat to consider
ESM only.js within a package marked "type": "module", or .mjsA 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 .cjsAppropriate 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 CommonJSSeparate ESM and CommonJS files selected with conditional exportsMore 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 module as 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.js does not become a reliable require() 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 dist directory is not proof that dist is in the tarball. Inspect npm pack --dry-run or 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 exports allows deep imports. It defines the public paths. Add subpath exports deliberately if consumers need them.

Best Practices

  1. Write down the supported runtimes and module formats before choosing TypeScript settings.
  2. Prefer the simplest output that serves your audience. Publish ESM only if that is the support contract you can stand behind.
  3. Keep runtime files, declarations, and exports entries aligned. Use explicit extensions and package scopes where they clarify module interpretation.
  4. Build before packing, and inspect the tarball as part of release checks.
  5. Test the installed tarball from a clean consumer project. Exercise every supported runtime and declaration branch.
  6. 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