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.
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.tsand notypesfield, so consumers getts(7016). - Masquerading: an ESM-only package whose
requireconsumers get.d.ctsthat describe CJS shapes the runtime cannot deliver, or vice versa. - Falsely ESM / falsely CJS: the
import/requiredeclaration disagrees with what the JS actually exports (a.d.mtsdescribing amodule.exports = ...default, for example). - Wrong
exportsordering:typesplaced afterdefault, sonode16resolution skips it. - Internal resolution errors: a published
.d.tsimports a relative file without the extensionnode16requires, 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
- Understanding package.json Fields — the
types,exports, andtypesVersionsfields that route consumers to your declarations. - ESM and CJS Interoperability — the dual-format runtime mechanics your declarations must mirror.
- Fixing 'Cannot Find Module' Type Declaration Errors — diagnosing
ts(2307)andts(7016)when declarations fail to resolve. - Generating Dual CJS/ESM Type Definitions — producing
.d.mtsand.d.ctsand wiring them into conditional exports. - Lockfile Management Strategies — pinning the toolchain so declaration builds are reproducible in CI.