TypeScript 103 🔷 Publishing Libraries with Types
A library that ships without types is a library that TypeScript consumers cannot use safely. They install it, import it, and immediately get the Could not find a declaration file error. The fix is not on their side — it is on yours. Publishing a TypeScript library means shipping the compiled JavaScript and the declaration files that describe its types, configured so that every consumer’s module resolution strategy can find them.
The TypeScript documentation is explicit: if your types are generated by your source code, publish them with your source code . The declaration: true compiler option produces .d.ts files from your .ts source. The types field in package.json points to the entry declaration file . For libraries that do not generate their own types, the alternative is contributing to DefinitelyTyped, which publishes to the @types organization on npm .
Key point: The types field alone is not enough for modern consumers. TypeScript’s moduleResolution: "node16", "nodenext", and "bundler" settings resolve types through the exports field in package.json, not the legacy types field . A library that only sets "types": "./dist/index.d.ts" works under the legacy node resolution and almost nowhere else . The modern package needs both: a types field for legacy consumers and an exports map with a types condition for modern ones.
Why publishing types is harder than it looks
The naive approach — set declaration: true, point types at the output, publish — works for a library consumed by an application with the same TypeScript configuration as the library. It fails for everyone else.
The module resolution problem. TypeScript has multiple module resolution strategies. The legacy "node" strategy reads the types and main fields. The modern "node16", "nodenext", and "bundler" strategies read the exports field . A library that only sets types is invisible to modern consumers. A library that only sets exports is invisible to legacy consumers. The package must serve both .
The dual-format problem. Many libraries ship both CommonJS and ES modules. The exports field has import and require conditions for the two formats . TypeScript needs a types condition in each block to know which declaration file corresponds to which JavaScript file . If the types condition is missing, TypeScript tries to read the .js file as a declaration and fails .
The subpath problem. Once a package defines an exports field, unlisted subpaths become inaccessible . If the library exposes my-lib/plugins/zod, the exports field must have an entry for that subpath. If it does not, the import fails under modern resolution even though the file exists on disk . The typesVersions field provides a fallback for legacy consumers who use subpath imports under the older node resolution strategy .
The tree-shaking problem. The sideEffects: false field tells bundlers that the package’s modules can be safely tree-shaken. Without it, bundlers assume every module has side effects and include everything, even unused exports. For a library with a large surface area, this can double the bundle size of the consuming application .
The trade-off. A correct package.json for a typed library is verbose. It has main, module, types, exports with multiple conditions, typesVersions, files, and sideEffects. Every field serves a specific consumer scenario. Omitting any of them breaks a subset of consumers. The verbosity is the price of compatibility with the full range of TypeScript and JavaScript module resolution strategies.
a. Generating Declaration Files
The first step is to configure TypeScript to emit declaration files alongside the JavaScript output. The declaration compiler option does this .
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}
The declaration: true option generates a .d.ts file for every .ts file. The declarationMap: true option generates a .d.ts.map file that allows editors to navigate from the declaration file back to the original source. This is what makes “Go to Definition” work for consumers who have the library installed .
The output goes to dist/. After running tsc, the directory contains the compiled JavaScript (index.js), the declaration file (index.d.ts), and the declaration map (index.d.ts.map). Only the dist/ directory is published; the src/ directory stays in the repository but is not included in the npm tarball .
For a library that needs to support both CommonJS and ES modules, the build runs twice with different module settings. The tsconfig.cjs.json sets "module": "CommonJS" and "outDir": "dist/cjs". The tsconfig.esm.json sets "module": "ESNext" and "outDir": "dist/esm" . Each build generates its own declaration files, or a single declaration build generates the .d.ts files that both formats reference.
b. Configuring package.json for Both Consumers
The package.json is where the declaration files are exposed to consumers. The main and types fields serve legacy consumers. The exports field serves modern consumers. Both must be present and correct.
{
"name": "my-library",
"version": "1.0.0",
"type": "module",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
},
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"*": ["./dist/types/*"]
}
},
"files": ["dist"],
"sideEffects": false
}
The types field at the top level is the entry point for legacy TypeScript resolution. It points to the declaration file for the package’s main entry .
The exports field is the modern resolution map. The . key is the package’s main entry. Inside it, the types condition must come first . The module resolution algorithm walks the conditions top-down and stops at the first match. If import comes before types, TypeScript resolves the .js file as the type definition and fails . The import condition points to the ES module build. The require condition points to the CommonJS build .
The "./package.json": "./package.json" entry exposes the package’s package.json to tools that need to read it, such as Vite, Webpack, and type-version detectors. Without this entry, those tools error under moduleResolution: "bundler" .
The typesVersions field is the legacy fallback for subpath imports. When a consumer uses moduleResolution: "node" and imports my-library/plugins/zod, the exports field is not consulted. The typesVersions map redirects the subpath to the declaration file in dist/types .
The files array is an allow-list. Only dist is published. The src directory, test files, and configuration files are excluded from the tarball .
The sideEffects: false field tells bundlers that the package’s modules can be tree-shaken. This is safe when the library does not have modules that execute code for side effects when imported .
c. Subpath Exports and typesVersions
A library with multiple entry points — a main module, a plugins directory, a testing utilities module — needs subpath exports. The exports field defines them. The typesVersions field provides the legacy fallback.
{
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
},
"./plugins/zod": {
"types": "./dist/types/plugins/zod.d.ts",
"import": "./dist/esm/plugins/zod.js",
"require": "./dist/cjs/plugins/zod.js"
},
"./testing": {
"types": "./dist/types/testing/index.d.ts",
"import": "./dist/esm/testing/index.js",
"require": "./dist/cjs/testing/index.js"
},
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"plugins/zod": ["./dist/types/plugins/zod.d.ts"],
"testing": ["./dist/types/testing/index.d.ts"],
"*": ["./dist/types/*"]
}
}
}
The plugins/zod subpath is available as my-library/plugins/zod under modern resolution and as my-library/plugins/zod under legacy resolution via the typesVersions map . The wildcard "*": ["./dist/types/*"] at the end of typesVersions handles any subpath that is not explicitly listed, redirecting it to the corresponding path in dist/types .
The typesVersions field is only needed for consumers using moduleResolution: "node" (often because they are running tests under Jest with module: "commonjs") . Modern consumers with "bundler", "node16", or "nodenext" resolution use the exports field exclusively. The library should maintain both during the transition period and drop typesVersions when legacy resolution is no longer a concern .
Complete Example Session
This session builds a TypeScript library with dual ESM/CJS output, declaration files, subpath exports, and a validated package.json.
// ============================================
// PART 1: THE SOURCE
// ============================================
// src/index.ts
export interface User {
id: string;
name: string;
email: string;
}
export function formatUser(user: User): string {
return `${user.name} <${user.email}>`;
}
export function createUser(id: string, name: string, email: string): User {
return { id, name, email };
}
// src/plugins/zod.ts
import { z } from 'zod';
export const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
export type UserFromZod = z.infer<typeof UserSchema>;
// src/testing/index.ts
export function createMockUser(overrides: Partial<User> = {}): User {
return {
id: '1',
name: 'Test User',
email: 'test@example.com',
...overrides,
};
}
// ============================================
// PART 2: THE TSCONFIG FILES
// ============================================
// tsconfig.json (base)
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
// tsconfig.esm.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"outDir": "dist/esm",
"declarationDir": "dist/types"
}
}
// tsconfig.cjs.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"outDir": "dist/cjs",
"declaration": false
}
}
// ============================================
// PART 3: THE PACKAGE.JSON
// ============================================
{
"name": "my-library",
"version": "1.0.0",
"description": "A typed library with dual ESM/CJS output",
"type": "module",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
},
"./plugins/zod": {
"types": "./dist/types/plugins/zod.d.ts",
"import": "./dist/esm/plugins/zod.js",
"require": "./dist/cjs/plugins/zod.js"
},
"./testing": {
"types": "./dist/types/testing/index.d.ts",
"import": "./dist/esm/testing/index.js",
"require": "./dist/cjs/testing/index.js"
},
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"plugins/zod": ["./dist/types/plugins/zod.d.ts"],
"testing": ["./dist/types/testing/index.d.ts"],
"*": ["./dist/types/*"]
}
},
"files": ["dist"],
"sideEffects": false,
"scripts": {
"build": "tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json",
"prepublishOnly": "npm run build",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"peerDependencies": {
"zod": "^3.23.0"
},
"devDependencies": {
"typescript": "^5.8.0",
"zod": "^3.23.0"
}
}
// ============================================
// PART 4: THE BUILD
// ============================================
npm run build
# Output:
# dist/
# ├── cjs/
# │ ├── index.js
# │ ├── plugins/zod.js
# │ └── testing/index.js
# ├── esm/
# │ ├── index.js
# │ ├── plugins/zod.js
# │ └── testing/index.js
# └── types/
# ├── index.d.ts
# ├── index.d.ts.map
# ├── plugins/zod.d.ts
# ├── plugins/zod.d.ts.map
# └── testing/index.d.ts
// ============================================
// PART 5: THE PACK VALIDATION
// ============================================
# npm pack creates the tarball and reports what is included
npm pack --dry-run
# npm notice Tarball Contents
# npm notice 1.2kB dist/cjs/index.js
# npm notice 0.8kB dist/cjs/plugins/zod.js
# npm notice 0.5kB dist/cjs/testing/index.js
# npm notice 1.1kB dist/esm/index.js
# npm notice 0.7kB dist/esm/plugins/zod.js
# npm notice 0.4kB dist/esm/testing/index.js
# npm notice 0.9kB dist/types/index.d.ts
# npm notice 0.6kB dist/types/plugins/zod.d.ts
# npm notice 0.5kB dist/types/testing/index.d.ts
# npm notice 2.1kB package.json
# The src/ directory is not included. Only dist/ and package.json.
// ============================================
// PART 6: THE CONSUMER VALIDATION
// ============================================
# Test with a modern consumer
npx arethetypeswrong --pack .
# ✔ The package is correct for all consumers.
# Test with the legacy node resolution
npx tsc --traceResolution --moduleResolution node --noEmit
# The types resolve to dist/types/index.d.ts.
# Test with the modern bundler resolution
npx tsc --traceResolution --moduleResolution bundler --noEmit
# The types resolve through the exports field.
// ============================================
// PART 7: THE PUBLISH
// ============================================
npm login
npm publish
# The prepublishOnly script runs the build.
# The tarball includes only dist/ and package.json.
// ============================================
// PART 8: THE SEMVER FOR TYPES
// ============================================
# v1.0.0 → v1.1.0 (minor): added optional field
export interface User {
id: string;
name: string;
email: string;
avatarUrl?: string; // new optional field
}
# v1.1.0 → v2.0.0 (major): removed a field
export interface User {
id: string;
name: string;
// email: string; // removed — breaking change
}
# v2.0.0 → v2.0.1 (patch): made type more specific
export interface User {
id: string;
name: string;
email: `${string}@${string}`; // more specific, usually safe
}
// ============================================
// PART 9: THE PUBLISHCONFIG FOR SCOPED PACKAGES
// ============================================
# For scoped packages, add publishConfig
{
"name": "@my-org/library",
"publishConfig": {
"access": "public"
}
}
# Scoped packages default to restricted access.
# The publishConfig overrides this.
// ============================================
// PART 10: THE CI PUBLISH WORKFLOW
// ============================================
# .github/workflows/publish.yml
name: Publish
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm run build
- run: npm test
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
The ten parts cover the source, the tsconfig files, the package.json, the build, the pack validation, the consumer validation, the publish, the semver for types, the publishConfig, and the CI publish workflow.
Quick Reference
The package.json Fields
| Field | Purpose | Required |
|---|---|---|
types | Legacy TypeScript entry point | Yes |
exports | Modern resolution map | Yes |
main | Legacy CommonJS entry | For CJS |
module | Legacy ESM entry (bundlers) | Optional |
typesVersions | Legacy subpath fallback | For legacy subpath consumers |
files | Publish allow-list | Yes |
sideEffects | Tree-shaking hint | Yes |
publishConfig | Registry and access | For scoped packages |
The exports Conditions
| Condition | Purpose | Order |
|---|---|---|
types | TypeScript declaration file | First |
import | ES module build | After types |
require | CommonJS build | After import |
default | Fallback condition | Last |
The Compiler Options
| Option | Purpose |
|---|---|
declaration | Emit .d.ts files |
declarationMap | Emit .d.ts.map for navigation |
outDir | Output directory |
rootDir | Source root |
module | Output format (ESNext or CommonJS) |
The Validation Tools
| Tool | Purpose |
|---|---|
npm pack --dry-run | Preview the tarball |
arethetypeswrong | Validate type resolution for all consumers |
tsc --traceResolution | Debug module resolution |
publint | Catch export/type issues |
Best Practices
✅ Do This:
// Put types first in every exports condition block
{ "types": "./dist/types/index.d.ts", "import": "./dist/esm/index.js" } // ✅
// Expose package.json in exports
{ "./package.json": "./package.json" } // ✅
// Use files to limit the published tarball
{ "files": ["dist"] } // ✅
// Add typesVersions for legacy subpath resolution
{ "typesVersions": { "*": { "*": ["./dist/types/*"] } } } // ✅
// Set sideEffects: false for tree-shaking
{ "sideEffects": false } // ✅
# Validate with arethetypeswrong before publishing
npx arethetypeswrong --pack . // ✅
❌ Don’t Do This:
// Don't omit the types condition from exports
{ "exports": { ".": { "import": "./dist/index.js" } } } // ❌ TS fails
// Don't put import before types in the condition block
{ "import": "./dist/index.js", "types": "./dist/index.d.ts" } // ❌
// Don't publish src/ in the tarball
{ "files": ["src", "dist"] } // ❌
// Don't forget the exports field for modern consumers
{ "types": "./dist/index.d.ts" } // no exports // ❌
// Don't use a caret range for TypeScript in devDependencies
{ "typescript": "^5.0.0" } // may pull breaking minor versions // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Modern consumers cannot find types | exports missing or types not first | Add exports with types first |
| Legacy consumers cannot find types | types field missing | Add types field |
| Subpath imports fail | exports does not list the subpath | Add explicit subpath entry |
| Jest tests fail to resolve types | Legacy node resolution | Add typesVersions fallback |
| Tarball includes src/ | files not set or includes src | Set "files": ["dist"] |
| Tree-shaking does not work | sideEffects not set | Add "sideEffects": false |
| Scoped package publish fails | publishConfig missing | Add "access": "public" |
Real-World Examples
1. Basic Types Field
{ "types": "./dist/index.d.ts" }
2. Modern Exports Map
{ "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } } }
3. Dual Format Exports
{ "exports": { ".": { "types": "...", "import": "...", "require": "..." } } }
4. Subpath Export
{ "./plugins/zod": { "types": "./dist/types/plugins/zod.d.ts", "import": "./dist/esm/plugins/zod.js" } }
5. typesVersions Fallback
{ "typesVersions": { "*": { "plugins/zod": ["./dist/types/plugins/zod.d.ts"] } } }
6. Files Allow-List
{ "files": ["dist"] }
7. Side Effects
{ "sideEffects": false }
8. PublishConfig for Scoped
{ "publishConfig": { "access": "public" } }
9. Declaration Map
{ "compilerOptions": { "declaration": true, "declarationMap": true } }
10. Validation
npx arethetypeswrong --pack .
Visual
The Package Resolution Layers
┌──────────────────────────────────────────────┐
│ PACKAGE RESOLUTION │
│ │
│ Legacy consumers: │
│ moduleResolution: "node" │
│ ├─ Reads: types, main │
│ └─ typesVersions for subpaths │
│ │
│ Modern consumers: │
│ moduleResolution: "bundler"/"node16" │
│ ├─ Reads: exports │
│ └─ types condition first │
│ │
│ Both must work. │
│ │
└──────────────────────────────────────────────┘
The exports Condition Order
┌──────────────────────────────────────────────┐
│ exports CONDITION ORDER │
│ │
│ "exports": { │
│ ".": { │
│ "types": "...", ← MUST be first │
│ "import": "...", │
│ "require": "..." │
│ } │
│ } │
│ │
│ Wrong order: │
│ "import": "...", ← matched first │
│ "types": "..." ← never reached │
│ │
│ TS reads the .js as types and fails. │
│ │
└──────────────────────────────────────────────┘
The Build Output
┌──────────────────────────────────────────────┐
│ dist/ │
│ ├── cjs/ │
│ │ ├── index.js │
│ │ └── plugins/zod.js │
│ ├── esm/ │
│ │ ├── index.js │
│ │ └── plugins/zod.js │
│ └── types/ │
│ ├── index.d.ts │
│ ├── index.d.ts.map │
│ └── plugins/zod.d.ts │
│ │
│ Only dist/ is published. │
│ src/ stays in the repo. │
│ │
└──────────────────────────────────────────────┘
The Semver for Types
┌──────────────────────────────────────────────┐
│ SEMVER FOR TYPES │
│ │
│ Major (breaking): │
│ ├─ Removing a field │
│ ├─ Changing a type to incompatible │
│ └─ Making optional required │
│ │
│ Minor (additive): │
│ ├─ Adding optional field │
│ ├─ Adding union member │
│ └─ Adding new export │
│ │
│ Patch (safe): │
│ └─ Making type more specific │
│ (usually safe, test thoroughly) │
│ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Declaration generation | declaration: true in tsconfig |
| Declaration maps | declarationMap: true for navigation |
| Legacy types entry | types field in package.json |
| Modern types entry | exports with types condition |
| Condition order | types must be first in each block |
| Subpath exports | Explicit entries in exports |
| Legacy subpath fallback | typesVersions |
| Publish allow-list | files array |
| Tree-shaking | sideEffects: false |
| Validation | arethetypeswrong, publint |
| Scoped packages | publishConfig.access: "public" |
Key takeaways:
- Generating declaration files is the first step. The
declaration: truecompiler option produces.d.tsfiles for every.tsfile. ThedeclarationMap: trueoption produces maps that enable cross-package navigation. Only thedist/directory is published . - The
typesfield serves legacy consumers; theexportsfield serves modern ones. Thetypesfield points to the entry declaration file formoduleResolution: "node". Theexportsfield with atypescondition serves"bundler","node16", and"nodenext". - The
typescondition must be first in everyexportsblock. TypeScript walks the conditions top-down and stops at the first match. Puttingimportbeforetypesmakes TypeScript read the.jsfile as types and fail . - Subpath exports must be explicit. Once
exportsis defined, unlisted subpaths are inaccessible. Every entry point the library exposes —./plugins/zod,./testing— needs an explicit entry in theexportsmap . typesVersionsprovides a fallback for legacy subpath resolution. Consumers usingmoduleResolution: "node"(often because of Jest withmodule: "commonjs") do not read theexportsfield. ThetypesVersionsmap redirects subpath imports to the corresponding declaration files .- The
filesarray limits the published tarball. Only thedist/directory andpackage.jsonare included. Thesrc/directory, tests, and configuration files stay in the repository . sideEffects: falseenables tree-shaking. Without it, bundlers assume every module has side effects and include everything, even unused exports. The flag is safe when the library’s modules do not execute code for side effects on import .
Remember: Publishing a typed library means shipping two artifacts: the JavaScript that runs and the declarations that describe it. The JavaScript needs main, module, and exports. The declarations need types, exports with a types condition, and typesVersions for legacy consumers. The exports field is the modern contract; the types field is the legacy fallback. The types condition must come first in every block. Validate with arethetypeswrong before publishing. The configuration is verbose because the module resolution ecosystem is diverse. Every field serves a consumer scenario. Get it right, and every consumer — modern or legacy, ESM or CJS, bundler or Jest — resolves your types correctly.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!