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.

Declaration resolution is hard because the type checker follows the same conditional exports machinery as the runtime, so a package that ships JavaScript correctly can still ship types incorrectly. Under modern node16/nodenext resolution, when a consumer imports your package the checker resolves the import branch's types, and when they require it the checker resolves the require branch's types — which means a single shared .d.ts is not enough for a dual-format package. The require branch needs a CommonJS-flavored .d.cts next to its .cjs file, or the consumer gets any or a wrong-shape type even though the code runs.

The difficulty compounds because these failures are invisible in your own repository. Your tests import the source directly and never exercise the published conditions, so the types look correct locally and fail only when a consumer resolves the published package under a resolution mode you did not test. This is the same late-failure pattern that plagues dual publishing generally, and it is why declaration correctness has to be verified against the packed tarball the way a consumer resolves it, not against your source.

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.

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 insid 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.
Declaring Types in the Manifest — the core idea of this section at a glance.

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.

The manifest is where a package tells type checkers where its declarations live, and getting it right requires understanding that modern resolution follows the same conditional exports as the runtime. A top-level types field is the legacy signal that older resolution modes read, but under node16/nodenext the checker resolves types per condition, so each export branch needs its own nested types entry pointing at the format-appropriate declaration. A package that declares only a top-level types works under bundler resolution and fails under node16, which is exactly the kind of mode-dependent breakage that is invisible until a consumer using strict resolution installs it.

The safest manifest for a dual-format package therefore nests a types condition inside both the import and require branches, pointing at a .d.ts and a .d.cts respectively, and keeps the top-level types as a fallback for legacy resolution. This belt-and-suspenders approach means every resolution mode a consumer might use finds a correct declaration, which is what the @arethetypeswrong/cli grid verifies. Declaring types precisely in the manifest is the difference between a package that resolves correctly everywhere and one that works only under the resolution mode you happened to test.

Emitting Declarations with the Compiler

declaration and declarationMap

Declaration emit Compiler emits, then declarations are bundled and validated. tsc --declaration emit .d.ts bundle types api-extractor / dts attw + publint verify resolution
Emit, bundle, then prove the types resolve before publishing.

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.

Emitting declarations is a separate compiler pass from transpiling JavaScript, and treating it as such prevents a class of silent failures. tsc --emitDeclarationOnly runs the type checker and writes .d.ts files without emitting JavaScript, which is useful when a fast transpiler like esbuild handles the JavaScript and you want types generated independently. The catch is that the declaration pass is a real type-check: if it fails, some pipelines log the error but still succeed at the JavaScript step, shipping a package with missing or partial declarations. Running the type-check as its own gate ensures a type error fails the build loudly rather than silently dropping types.

For a dual-format package the compiler must produce both declaration flavors, which usually means emitting .d.ts for the ESM build and .d.cts for the CommonJS build, either through two compiler configurations or a tool that generates both. Each must sit next to its corresponding JavaScript file and be referenced by a nested types condition in the exports map, so the checker resolves the format-appropriate declaration for each import style.

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.

Declaration bundling Loose per-file declarations versus a single bundled entry. Loose .d.ts tree • Every internal file emitted • consumers import deep paths • refactors break types Bundled declarations • One public entry .d.ts • internals hidden • stable type surface
Bundling declarations stops deep-path type leaks and speeds consumer builds.

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',
});

A bundled declaration is produced by a tool that rolls the internal .d.ts files up behind the public entry, so consumers see one file exposing exactly what the entry re-exports:

// api-extractor.json (excerpt)
{
  "mainEntryPointFilePath": "/dist/index.d.ts",
  "dtsRollup": {
    "enabled": true,
    "publicTrimmedFilePath": "/dist/index.public.d.ts"
  }
}

The rollup hides internal types behind the public surface, so a refactor that moves internal types around is not a breaking change as long as the re-exported API is unchanged. It also speeds a consumer's type-check by giving them a single file instead of your whole declaration tree, and it prevents deep-imports of internal types that would otherwise couple consumers to a layout you want to keep free to change.

"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:

"Are the types wrong?" — the pitfalls that ship broken A package can publish, install, and run, yet still serve broken types. "Are the types wrong?" — the pitfalls that ship broken A package can publish, install, and run, yet still serve broken types.
"Are the types wrong?" — the pitfalls that ship broken — the core idea of this section at a glance.
  • 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.

The pitfalls that ship broken types are almost always resolution-mode-dependent, which is why they escape a source-importing test. A single shared .d.ts works under bundler resolution and fails under node16 when a consumer requires the package, because the checker resolves the require branch's types and finds none; a declaration that references an internal type the files allowlist excludes resolves to any; and a top-level types without nested per-condition entries leaves modern resolvers without a format-appropriate declaration. Each looks correct locally and fails only for a consumer using a resolution mode you did not test, which is exactly what the @arethetypeswrong/cli grid exists to surface.

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.

CI/CD: build and validate types This workflow compiles declarations, packs the tarball, and validates that types resolve under each consumer mode before 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.
CI/CD: build and validate types — the core idea of this section at a glance.
# .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

A type-validation job resolves the packed package the way a consumer will and fails on any mismatch, turning a broken declaration into a red build rather than a consumer's editor error:

jobs:
  types:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: 10 }
      - run: pnpm install --frozen-lockfile --ignore-scripts
      - run: pnpm exec tsc --noEmit            # type error fails loudly, not silently
      - run: pnpm build                        # emits .d.ts and .d.cts
      - run: pnpm exec publint                 # structural exports checks
      - run: pnpm exec attw --pack .           # resolution grid across modes

Running tsc --noEmit before the build ensures a type error fails the job rather than silently dropping declarations, and publint plus @arethetypeswrong/cli resolve the packed tarball across every module and resolution combination, so a missing .d.cts or a reordered condition is caught on the pull request that introduced it.

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.
Common Pitfalls & Remediation Common Pitfalls & Remediation in production JavaScript package workflows. Common Pitfalls & Remediation Common Pitfalls & Remediation in production JavaScript package workflows.
Common Pitfalls & Remediation — the core idea of this section at a glance.

Bundling declarations and hiding internals

Emitting one declaration file per source file leaks your internal module structure into the public type surface, which has two costs: consumers can deep-import internal types and couple to a layout you want to refactor, and their type-checker must process your entire declaration tree. Bundling the declarations into a single public entry — with a tool like api-extractor or a dts bundler — hides the internals behind the entry point's public API and speeds the consumer's type-check by giving them one file instead of many.

Declaration bundling Per-file declarations versus a single bundled entry. Per-file .d.ts • internal layout exposed • consumers deep-import • refactors break types Bundled entry • one public .d.ts • internals hidden • stable type surface
Bundling hides internals and gives consumers one stable, minimal type contract.

Bundling also makes the type surface a deliberate decision rather than an accident of file layout. A bundled declaration exposes exactly what the entry re-exports and nothing else, so a refactor that moves internal types around is not a breaking change as long as the public surface is unchanged. The trade-off is that declaration bundling adds a build step and can occasionally mishandle complex type constructs, so it is worth validating the bundled output resolves correctly. For a library with a clear public API and internals worth protecting, bundled declarations are the difference between a stable, minimal type contract and one that exposes and couples consumers to every internal file.

A bundled declaration is produced by a tool that rolls the internal .d.ts files up behind the public entry, so consumers see one file exposing exactly what the entry re-exports:

// api-extractor.json (excerpt)
{
  "mainEntryPointFilePath": "/dist/index.d.ts",
  "dtsRollup": {
    "enabled": true,
    "publicTrimmedFilePath": "/dist/index.public.d.ts"
  }
}

The rollup hides internal types behind the public surface, so a refactor that moves internal types around is not a breaking change as long as the re-exported API is unchanged. It also speeds a consumer's type-check by giving them a single file instead of your whole declaration tree, and it prevents deep-imports of internal types that would otherwise couple consumers to a layout you want to keep free to change.

Validating types the way a consumer resolves them

Type correctness is verifiable, so a mature package proves it in CI against the packed tarball rather than trusting the local build. @arethetypeswrong/cli packs your package and reports a grid of every module-and-resolution combination a consumer might use — node16 import, node16 require, bundler, legacy — flagging a false CJS, a missing-types, or a resolution-mismatch cell precisely. publint complements it by checking the exports map for structural mistakes. Running both turns a broken declaration from a bug reported weeks later, in a consumer's editor, into a red build on the pull request that introduced it.

Type validation attw and publint on the tarball, plus a node16 smoke test. attw --pack every resolution mode publint exports structure node16 smoke test types resolve
Validating against the packed tarball catches resolution failures before a consumer hits them.

Complement the static checkers with a resolution smoke test: import the built package from a .mts file and require it from a .cts file, then type-check both under node16 resolution. This exercises the real conditional resolution end to end — the declarations resolve to the format-appropriate file for each import style — catching the reordered condition and the missing .d.cts that structural linting alone can miss. The principle is that the artifact, not the source, is what a consumer resolves, so the artifact is what you verify; a package whose types are checked this way in CI simply cannot ship the resolution failures that are otherwise the most common declaration bug.

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.

Why do I need both .d.ts and .d.cts?

Under node16/nodenext resolution the type checker follows the same conditional exports as the runtime, so the require branch needs a CommonJS-flavored .d.cts next to its .cjs file. A single shared .d.ts leaves require consumers with wrong-shape types or any, even when the JavaScript resolves correctly.

Why bundle declarations instead of emitting one per file?

Per-file declarations leak your internal module structure, letting consumers deep-import internal types and slowing their type-check. Bundling into a single public entry hides internals, speeds the consumer's build, and makes the type surface a deliberate contract rather than an accident of layout.

How do I catch a broken type export before a consumer does?

Run @arethetypeswrong/cli and publint against the packed tarball in CI, plus a smoke test that imports and requires the build and type-checks under node16. Together they resolve types the way a consumer will, so a mismatch fails your pipeline instead of their editor.

What's the one check that catches most declaration bugs?

@arethetypeswrong/cli run against the packed tarball in CI. It resolves your types across every module and resolution mode a consumer might use, so a missing .d.cts, a reordered condition, or a wrong-format declaration fails your build instead of a consumer's editor.

Related

Core JavaScript Package Workflows