Back to core workflows Fix dependency resolution Tune package metadata Jump to monorepo patterns

TypeScript Declaration Publishing

Shipping .d.ts files that resolve correctly under every consumer's tsconfig.json is the difference between a package that "just works" and one that floods editors with red squiggles. This guide covers the manifest fields, compiler flags, and conditional exports ordering that make your declarations resolvable from CommonJS, native ESM, and bundler toolchains alike — and the validation steps that keep them correct release after release.

Why Declaration Resolution Is Hard

A consumer's TypeScript compiler does not read your source. It reads the .d.ts files you publish and trusts them completely. If those files are missing, mistyped in the manifest, or ordered incorrectly inside your exports map, the consumer sees ts(7016) or ts(2307) errors even though your JavaScript runs fine. Declaration resolution is governed by the same moduleResolution algorithm that resolves runtime modules, so a package that runs correctly can still be untyped if its types are wired up wrong.

This is a supporting topic under Core JavaScript Package Workflows. It builds directly on the manifest mechanics in Understanding package.json Fields and the dual-format mechanics in ESM and CJS Interoperability. When declaration resolution fails, the two most common failure modes get their own deep dives: Fixing 'Cannot Find Module' Type Declaration Errors for the resolution errors, and Generating Dual CJS/ESM Type Definitions for shipping .d.mts and .d.cts side by side.

From source to typed consumer TypeScript source compiles to declaration files, the package manifest maps them, and each consumer resolution mode picks the matching declaration. src/index.ts typed source (not published) tsc emitDeclarationOnly + declarationMap index.d.mts index.d.cts package.json exports "types" map node16 / CJS require → .d.cts node16 / ESM import → .d.mts bundler types condition
Source compiles to declarations; the manifest's exports map routes each consumer resolution mode to the matching declaration file.

Declaring Types in the Manifest

There are two ways to point consumers at your declarations: the legacy types field and the per-condition types key inside exports. Modern packages need both — the top-level field for old resolvers, and the conditional keys for node16/nodenext and bundler resolution.

The types field (and typesVersions)

The top-level types field (an alias of typings) is the universal fallback. Any consumer whose resolver does not understand exports — including older moduleResolution: "node" setups — falls back to it.

{
  "name": "@scope/widget",
  "version": "1.0.0",
  "types": "./dist/index.d.ts",
  "main": "./dist/index.cjs"
}

typesVersions lets you ship different declarations to different TypeScript versions, which matters when you use syntax (like const type parameters or the using keyword) that older compilers cannot parse. Resolution is matched top-to-bottom against the consumer's TypeScript version range:

{
  "typesVersions": {
    ">=5.0": { "*": ["./dist/ts5/*"] },
    "*": { "*": ["./dist/ts4/*"] }
  }
}

Use typesVersions sparingly. It is the only mechanism for subpath type mapping on resolvers that ignore exports, but it does not compose cleanly with conditional exports and is easy to get subtly wrong.

The exports "types" condition — ordering is load-bearing

Under moduleResolution: "node16", "nodenext", or "bundler", TypeScript reads the exports map and looks for a types condition. The single most common publishing bug is putting types in the wrong position. The types condition must come first within each condition object, before import, require, or default. Conditions are matched in source order, so if default or import appears before types, the resolver matches the JavaScript file and never reaches the declaration.

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

For dual packages, each format gets its own nested types so ESM consumers receive .d.mts and CJS consumers receive .d.cts. This nested form is covered end-to-end in Generating Dual CJS/ESM Type Definitions:

{
  "exports": {
    ".": {
      "import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" },
      "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
    }
  }
}

Note that types appears first inside each nested import/require object too. The outer order (import then require) is fine because those are runtime conditions; what matters is that types precedes default within each leaf.

Emitting Declarations with the Compiler

declaration and declarationMap

declaration: true tells tsc to emit .d.ts alongside .js. declarationMap: true emits .d.ts.map files that link each declaration back to its source line, so a consumer's "Go to Definition" jumps into your original .ts rather than the generated .d.ts. Ship declaration maps only if you also ship the source files they reference; otherwise the maps point at nothing.

{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true,
    "emitDeclarationOnly": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2022",
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}

tsc --emitDeclarationOnly for type/JS split builds

When a bundler (esbuild, swc, Rollup) produces your runtime JavaScript, you do not want tsc racing to emit the same .js. Run tsc purely for types with --emitDeclarationOnly, and let the faster tool handle transpilation:

# Bundler emits JS; tsc emits only the declarations
tsc -p tsconfig.build.json --emitDeclarationOnly

This split is the standard pattern: bundlers transpile far faster than tsc but produce weaker or no declarations, so tsc (or a declaration bundler) owns the .d.ts step exclusively.

moduleResolution: node16/nodenext vs bundler

The declarations you emit must be authored for the resolution mode your consumers use:

Mode Who uses it Behavior with your .d.ts
node16 / nodenext Node.js libraries, anything published to npm Reads exports conditions; enforces explicit file extensions in relative imports inside your .d.ts; distinguishes .d.mts vs .d.cts.
bundler Apps built with Vite, webpack, esbuild Reads exports types condition but does not require extensions; does not split .d.mts/.d.cts.
node (legacy) Old projects Ignores exports entirely; only the top-level types field works.

Authoring for node16 is the safest target because its declarations resolve correctly under bundler too, but the reverse is not true. The interaction between these modes is the most common source of Fixing 'Cannot Find Module' Type Declaration Errors.

Bundling Declarations

By default tsc emits one .d.ts per source file, mirroring your src/ tree. That works, but it exposes internal modules and can produce dozens of files. Declaration bundlers roll the public surface into a single index.d.ts and strip non-exported internals.

api-extractor

Microsoft's api-extractor rolls up declarations and can also produce an API report and trimmed public/beta/internal variants. It expects a single entry .d.ts produced by tsc:

{
  "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
  "mainEntryPointFilePath": "<projectFolder>/dist/index.d.ts",
  "dtsRollup": {
    "enabled": true,
    "untrimmedFilePath": "<projectFolder>/dist/bundled.d.ts"
  },
  "compiler": { "tsconfigFilePath": "<projectFolder>/tsconfig.json" }
}
tsc -p tsconfig.build.json --emitDeclarationOnly
api-extractor run --local --verbose

dts-bundle / tsup dts

For lighter-weight needs, dts-bundle-generator or tsup's built-in dts: true flatten declarations without the full api-extractor pipeline. tsup is the path of least resistance for dual builds, since it emits JS and rolled-up declarations in one pass — see Generating Dual CJS/ESM Type Definitions for the dual-format configuration.

// tsup.config.ts
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],
  dts: true,        // emits index.d.ts / .d.mts / .d.cts per format
  clean: true,
  outDir: 'dist',
});

"Are the types wrong?" — the pitfalls that ship broken

A package can publish, install, and run, yet still serve broken types. The canonical failure categories are worth memorizing because each maps to a fixable manifest or compiler mistake:

  • No types: the package ships JS but no .d.ts and no types field, so consumers get ts(7016).
  • Masquerading: an ESM-only package whose require consumers get .d.cts that describe CJS shapes the runtime cannot deliver, or vice versa.
  • Falsely ESM / falsely CJS: the import/require declaration disagrees with what the JS actually exports (a .d.mts describing a module.exports = ... default, for example).
  • Wrong exports ordering: types placed after default, so node16 resolution skips it.
  • Internal resolution errors: a published .d.ts imports a relative file without the extension node16 requires, breaking the consumer's type-check.

The automated way to catch these is an "are the types wrong" style check, which simulates resolution under every mode and reports which consumers see broken types. Wire it into CI (below) so a regression fails the build instead of reaching the registry.

CI/CD: build and validate types

This workflow compiles declarations, packs the tarball, and validates that types resolve under each consumer mode before anything reaches the registry. Pin the package manager via packageManager as described in Lockfile Management Strategies so the toolchain is reproducible.

# .github/workflows/types.yml
name: Build & Validate Types
on:
  push:
    branches: [main]
  pull_request:

jobs:
  types:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      # Reproducible install — fails if the lockfile is stale
      - run: npm ci

      # Type-check the whole project without emitting (catches source errors)
      - name: Type-check source
        run: npx tsc --noEmit -p tsconfig.json

      # Emit declarations only; the bundler handles runtime JS separately
      - name: Build declarations
        run: |
          npx tsc -p tsconfig.build.json --emitDeclarationOnly
          test -f dist/index.d.ts || { echo "::error::index.d.ts missing"; exit 1; }

      # Build the runtime artifacts (dual ESM/CJS)
      - name: Build runtime
        run: npx tsup

      # Pack the exact tarball npm would publish
      - name: Pack tarball
        run: npm pack --pack-destination ./pack

      # Validate resolution across node16 ESM/CJS and bundler modes.
      # Fails the build if any consumer mode sees broken or missing types.
      - name: Check types resolve
        run: npx --yes @arethetypeswrong/cli --pack ./ --format table

      # Smoke-test that a fresh consumer can import the packed tarball and see types
      - name: Consumer smoke test
        run: |
          mkdir -p /tmp/consumer && cd /tmp/consumer
          npm init -y >/dev/null
          npm install "$GITHUB_WORKSPACE"/pack/*.tgz
          printf '{"compilerOptions":{"module":"NodeNext","moduleResolution":"NodeNext","noEmit":true,"strict":true}}' > tsconfig.json
          printf "import pkg from '@scope/widget';\nconsole.log(pkg);\n" > index.ts
          npx --yes typescript tsc -p tsconfig.json

Common Pitfalls & Remediation

Mistake Consequence Fix
types condition placed after import/require/default in exports node16 resolution matches the JS file and reports ts(7016) — no types found. Move types to the first key within each condition object.
Single index.d.ts reused for both import and require Under node16, ESM consumers may see CJS-shaped types (or vice versa), causing "masquerading" errors. Emit .d.mts and .d.cts and reference each from its matching condition.
Only a top-level types field, no exports types condition Works under legacy node resolution but breaks under node16/bundler. Add types conditions to every entry in the exports map.
declarationMap: true without shipping src/ "Go to Definition" lands on a missing file; editor errors. Include src/** in files, or drop declarationMap from published builds.
Relative imports in published .d.ts lack file extensions node16 consumers cannot resolve the internal module; type-check fails. Author source with explicit .js extensions, or bundle declarations so internals disappear.
Declarations never validated before publish Broken types reach the registry and every consumer at once. Run an "are the types wrong" check and a consumer smoke test in CI.

Frequently Asked Questions

Do I still need the top-level types field if I have exports types conditions? Yes. Resolvers running legacy moduleResolution: "node" ignore exports entirely and only read the top-level types field. Keep both so old and new consumers are covered.

Why does my package work at runtime but show "no types" in the editor? Runtime resolution and type resolution are separate passes. Your import/require/default conditions can point at valid JS while the types condition is missing, misordered, or points at a nonexistent file. Validate with an "are the types wrong" check to see exactly which mode fails.

Should I bundle my declarations or ship one file per module? Bundle them if you have internal modules you do not want to expose or if you ship many files. api-extractor and tsup's dts both roll the public surface into a single declaration and strip internals. Per-file output is fine for small packages with a clean public surface.

What is the difference between node16 and bundler for declarations? node16/nodenext mirrors Node.js resolution: it reads exports, enforces explicit extensions in relative imports, and distinguishes .d.mts from .d.cts. bundler reads the types condition but is lenient about extensions and does not split formats. Author for node16 and your types also work under bundler.

Can I publish only declarations from tsc while a bundler builds the JS? Yes — that is the recommended split. Run tsc --emitDeclarationOnly for types and let esbuild/swc/tsup transpile the runtime. It avoids two tools fighting over the same .js output and keeps builds fast.

Related

Core JavaScript Package Workflows