| |

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

FieldPurposeRequired
typesLegacy TypeScript entry pointYes
exportsModern resolution mapYes
mainLegacy CommonJS entryFor CJS
moduleLegacy ESM entry (bundlers)Optional
typesVersionsLegacy subpath fallbackFor legacy subpath consumers
filesPublish allow-listYes
sideEffectsTree-shaking hintYes
publishConfigRegistry and accessFor scoped packages

The exports Conditions

ConditionPurposeOrder
typesTypeScript declaration fileFirst
importES module buildAfter types
requireCommonJS buildAfter import
defaultFallback conditionLast

The Compiler Options

OptionPurpose
declarationEmit .d.ts files
declarationMapEmit .d.ts.map for navigation
outDirOutput directory
rootDirSource root
moduleOutput format (ESNext or CommonJS)

The Validation Tools

ToolPurpose
npm pack --dry-runPreview the tarball
arethetypeswrongValidate type resolution for all consumers
tsc --traceResolutionDebug module resolution
publintCatch 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

PitfallWhy It HappensFix
Modern consumers cannot find typesexports missing or types not firstAdd exports with types first
Legacy consumers cannot find typestypes field missingAdd types field
Subpath imports failexports does not list the subpathAdd explicit subpath entry
Jest tests fail to resolve typesLegacy node resolutionAdd typesVersions fallback
Tarball includes src/files not set or includes srcSet "files": ["dist"]
Tree-shaking does not worksideEffects not setAdd "sideEffects": false
Scoped package publish failspublishConfig missingAdd "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

ItemValue
Declaration generationdeclaration: true in tsconfig
Declaration mapsdeclarationMap: true for navigation
Legacy types entrytypes field in package.json
Modern types entryexports with types condition
Condition ordertypes must be first in each block
Subpath exportsExplicit entries in exports
Legacy subpath fallbacktypesVersions
Publish allow-listfiles array
Tree-shakingsideEffects: false
Validationarethetypeswrong, publint
Scoped packagespublishConfig.access: "public"

Key takeaways:

  • Generating declaration files is the first step. The declaration: true compiler option produces .d.ts files for every .ts file. The declarationMap: true option produces maps that enable cross-package navigation. Only the dist/ directory is published .
  • The types field serves legacy consumers; the exports field serves modern ones. The types field points to the entry declaration file for moduleResolution: "node". The exports field with a types condition serves "bundler", "node16", and "nodenext" .
  • The types condition must be first in every exports block. TypeScript walks the conditions top-down and stops at the first match. Putting import before types makes TypeScript read the .js file as types and fail .
  • Subpath exports must be explicit. Once exports is defined, unlisted subpaths are inaccessible. Every entry point the library exposes — ./plugins/zod, ./testing — needs an explicit entry in the exports map .
  • typesVersions provides a fallback for legacy subpath resolution. Consumers using moduleResolution: "node" (often because of Jest with module: "commonjs") do not read the exports field. The typesVersions map redirects subpath imports to the corresponding declaration files .
  • The files array limits the published tarball. Only the dist/ directory and package.json are included. The src/ directory, tests, and configuration files stay in the repository .
  • sideEffects: false enables 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!