Publishing and Consuming TypeScript Packages with npm
Understand how package.json declares a project's dependencies and scripts, how semantic versioning and lock files keep installs reproducible, and how to publish a TypeScript package with correct declaration files for both ESM and CommonJS consumers.
Writing correct TypeScript inside your own project is only half the story — the moment you depend on other packages, or publish one yourself, you are also depending on npm's conventions for versioning, resolution, and how .d.ts declaration files travel alongside compiled JavaScript. Getting this wrong doesn't produce a compiler error in your own editor; it produces a broken install for every consumer of your package, which is a much worse place to discover the problem.
package.json: dependencies, scripts, and semantic versioning
Every npm package is described by a package.json manifest. Its dependencies field lists packages your code imports at runtime and needs installed wherever it runs; devDependencies lists packages only needed while developing or building — test runners, bundlers, TypeScript itself — that a consumer of your published package should never have to install. peerDependencies is a third, easily-confused category: it declares a package your code expects the *consumer's* project to already provide a compatible version of, typically because your package is a plugin for it (a React component library listing react as a peer dependency rather than a regular dependency, so it doesn't ship a second copy of React).
The scripts field maps short names to shell commands, run with npm run <name> (a handful of names — start, test, and a few others — can be run without the word 'run'). A typical TypeScript package's scripts include build (compile with tsc), test, and prepublishOnly or prepare, which npm runs automatically before publishing so a package is never published with stale or missing compiled output.
Version numbers follow semantic versioning: MAJOR.MINOR.PATCH. A patch bump means a bug fix with no API change; a minor bump adds functionality in a backward-compatible way; a major bump breaks backward compatibility. The caret (^1.2.3) and tilde (~1.2.3) prefixes in a dependency's version range tell npm how much drift is acceptable on install: caret allows minor and patch updates (anything less than 2.0.0), tilde allows only patch updates (anything less than 1.3.0). Publishing a breaking change under a minor or patch version number is the single most common way a package author breaks every project depending on them.
Lock files and reproducible installs
package-lock.json (or yarn.lock, pnpm-lock.yaml) records the exact resolved version of every package in your dependency tree, including transitive dependencies your own package.json never mentions directly. Without it, two installs of the same package.json — run days apart, or on two different machines — could resolve to different actual versions within the ranges your semver ranges allow, silently changing behavior.
npm install reads package.json, resolves ranges, and updates the lock file if anything changed; npm ci (clean install) instead reads the lock file only, installs exactly what it specifies, and fails outright if package.json and the lock file have drifted out of sync. CI pipelines and Docker builds should always use npm ci, not npm install, precisely because it refuses to silently reinterpret a stale lock file — it makes an out-of-date lock file a build failure instead of a surprise.
Lock files should always be committed to version control, for both applications and libraries. Treating them as generated noise defeats their entire purpose: the whole point is that everyone who runs npm ci against the same commit gets bit-for-bit the same dependency tree.
Publishing declaration files: types, declaration emit, and files
A published TypeScript package needs two things alongside its compiled JavaScript: the JavaScript itself, and a .d.ts declaration file describing its types, so consumers get autocomplete and type checking without needing your original source. Setting "declaration": true in tsconfig.json makes tsc emit a matching .d.ts file next to each compiled .js file; "declarationMap": true additionally emits a source map for the declarations, so 'go to definition' in an editor can jump through the .d.ts straight to your original .ts source instead of stopping at the generated types.
The package.json "types" field (an older alias, "typings", still works but types is now conventional) points at the entry declaration file, mirroring how "main" points at the entry JavaScript file — "main": "dist/index.js", "types": "dist/index.d.ts". Without this field, or without shipping the .d.ts files at all, consumers importing your package in TypeScript either get no types (falling back to any) or must install a separate community-maintained @types/your-package package, which most authors would rather avoid needing.
The "files" field is an allowlist of what actually gets included when the package is packed and published — it should include your compiled output directory (commonly dist) and exclude source, tests, and config. Forgetting to list dist in files is a classic mistake: the package publishes successfully, npm install succeeds, and only when a consumer tries to import from it do they discover the compiled files were never actually included in the published tarball.
Dual ESM/CJS publishing, the exports map, and typesVersions
Node.js supports two module systems — CommonJS (require) and ECMAScript Modules (import) — and a published package may need to serve both kinds of consumer. The package.json "exports" field replaces the older single "main" entry point with a conditional map: different files are resolved depending on whether the consumer is using require or import, and whether TypeScript itself is resolving types versus a JavaScript runtime resolving executable code. A "types" condition inside exports must be listed *before* "import"/"require" in each conditional block, because condition order is significant — TypeScript picks the first matching condition it understands.
The "imports" field (distinct from "exports") lets a package define internal-only path aliases, conventionally prefixed with #, usable from the package's own source but never exposed to consumers — useful for swapping an internal module between environments (Node vs browser builds) without leaking that indirection into the public API.
"typesVersions" lets a package ship different .d.ts files for different ranges of the *consuming* project's TypeScript version, remapping import specifiers when an older TypeScript version can't understand a newer declaration syntax. It's a compatibility escape hatch, not something most packages need on day one — but any package supporting a wide range of TypeScript versions across years of consumers eventually runs into a case where its newest declaration features aren't understood by an older compiler.
For packages whose public API surface grows large, tools like Microsoft's API Extractor go a step further: they roll up all of a package's internal .d.ts files into a single bundled, API-reviewed .d.ts, strip internal-only types, and generate a versioned API report used to catch accidental breaking changes to the public type surface during code review. You won't need this for a small utility package, but it's worth recognizing the name when you see it in a larger library's build tooling — it's solving exactly the 'did we just silently change our public types' problem.
A dual ESM/CJS package.json with a conditional exports map
main/types at the top level are a fallback for older tools that don't understand exports at all; the exports block is what modern Node and TypeScript actually resolve through when both are present. prepublishOnly guarantees npm publish always runs a fresh build first, so the published dist folder can never be stale relative to the current source.
{
"name": "@example/unit-tools",
"version": "1.2.0",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"types": "dist/cjs/index.d.ts",
"exports": {
".": {
"types": "./dist/esm/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
},
"files": ["dist"],
"scripts": {
"build": "tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json",
"prepublishOnly": "npm run build"
},
"devDependencies": {
"typescript": "^5.4.0"
},
"peerDependencies": {
"typescript": ">=4.7"
}
}Code Examples
The caret on lodash allows npm to install any 4.x.x release at or above 4.17.21 (up to, but not including, 5.0.0) — minor and patch updates are trusted to be backward compatible. The tilde on left-pad is stricter, allowing only patch updates within 1.3.x (up to, but not including, 1.4.0).
"lodash": "^4.17.21" Common mistake: Assuming ^ and ~ behave the same way is a common source of confusion — ^ is the more permissive of the two and is npm's default when you run npm install <pkg>.
declaration emits a .d.ts file for every compiled module in dist, mirroring the src structure under rootDir; declarationMap adds .d.ts.map files so editors can jump from the emitted types back to the original .ts source instead of stopping at the generated declaration. Expected result: dist/index.js, dist/index.d.ts, dist/index.d.ts.map
{
"dependencies": {
"lodash": "^4.17.21",
"left-pad": "~1.3.0"
}
}{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": "src",
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["src"]
}How a consumer's import resolves through the exports map
The same import specifier resolves to a different physical file depending on which condition the caller matches — type checking always follows the types condition, regardless of which JS file actually executes.
consumer code
import { convert } from "@example/unit-tools";
|
v
package.json "exports" -> "."
|
+-----+------+------------------+
| | |
"types" "import" "require"
| | |
dist/esm/ dist/esm/ dist/cjs/
index.d.ts index.js index.js
(TS checker (ESM runtime, (CommonJS runtime,
reads this) e.g. Vite/Node) e.g. older Node require())Important Rules
Put runtime imports in dependencies, build/test-only tools in devDependencies, and host-provided packages your code expects but doesn't bundle in peerDependencies.
Always commit the lock file, and use npm ci (not npm install) in CI and Docker builds so a stale lock file fails the build instead of silently drifting.
Enable declaration: true for any package you intend to publish, and point package.json's types field at the emitted entry .d.ts.
List "types" before "import"/"require" inside each exports condition block — TypeScript matches conditions in order.
Double-check the files field actually includes your build output directory before publishing; npm pack --dry-run shows exactly what would be published.
Where this bites real packages
A huge share of 'why does this package show as any in my editor' bug reports trace back to exactly the issues in this lesson: a missing types field, a files array that forgot to include dist, or an exports map with types in the wrong position. Popular libraries like date-fns and zod ship carefully maintained dual ESM/CJS builds with explicit exports maps for this exact reason — supporting both require() and import consumers, across multiple TypeScript resolution modes, without either group losing type information.
"exports": { ".": { "import": "./dist/esm/index.js", "require": "./dist/cjs/index.js", "types": "./dist/esm/index.d.ts" } }
"exports": { ".": { "types": "./dist/esm/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/cjs/index.js" } }
TypeScript resolves exports conditions in the order they're written and stops at the first one it understands; listing "types" last means some resolution modes never reach it, and the consumer silently falls back to no types (implicit any) instead of an error telling you why.
[multiple-choice] A React component library depends on react to render but doesn't want to bundle its own copy or force a version mismatch with the consumer's app. Where should react be listed? A. dependencies B. devDependencies C. peerDependencies D. it shouldn't be listed at all
C. peerDependencies — peerDependencies declares that the library expects the consuming project to already provide a compatible version, avoiding duplicate copies and version conflicts of a shared package like React.
[true-false] npm ci will happily install a slightly different set of versions than the lock file if package.json's ranges allow it. A. True B. False
B. False — npm ci installs exactly what the lock file specifies and fails if the lock file and package.json are out of sync, unlike npm install which may update the lock file to satisfy new ranges.
[multiple-choice] Inside a package.json exports condition block, why must "types" be listed before "import" and "require"? A. Alphabetical order is required by npm B. TypeScript matches conditions in order and stops at the first one it understands C. It has no effect, it's purely a style convention D. "types" must always be the last key in any JSON object
B. TypeScript matches conditions in order and stops at the first one it understands — TypeScript's exports resolution walks the condition list in order; placing types after import/require risks it never being reached in some resolution modes, silently losing type information.
You're publishing a small package that only needs to support modern ESM consumers (no CommonJS require needed) and TypeScript 5+. Write the package.json fields (main/types/exports/files) for an entry point compiled to dist/index.js and dist/index.d.ts.
{ "main": "dist/index.js", "types": "dist/index.d.ts", "exports": "./dist/index.js", "files": ["dist"] }
You don't need a conditional exports object with multiple branches if there's only one format to support — a single string value for the "." export is valid.