Declaration File Generation and Type Stripping
Generate accurate .d.ts declaration files for dual ESM/CJS packages. Configure tsc declaration emit, bundler-driven extraction, and CI validation to ship type-correct distributions.
Without .d.ts declaration files, consumers of your library lose IDE autocompletion, cannot run tsc against your API, and may see Could not find a declaration file for module errors at build time — even when your JavaScript runs perfectly. The problem compounds with dual ESM/CJS packages: each output format needs its own correctly-pathed declarations, and a single misconfiguration silently breaks one entire module system for every downstream project. This page covers how to configure declaration emission, choose a bundler-driven extraction strategy, flatten path aliases, and validate the output before publishing — across Node.js 18+, TypeScript 4.9–5.x, and the most common bundlers.
Prerequisites
How Declaration Emission Fits the Build Pipeline
The diagram below shows where .d.ts files appear in a dual ESM/CJS build and how they map to package.json exports. Understanding this flow prevents the most common class of “types are missing” bugs.
Canonical Configuration Block
The single most important configuration is the tsconfig.json that governs declaration emit. Everything downstream — bundler config, CI scripts, package.json exports — depends on this being correct.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "./dist/types",
"rootDir": "./src",
"strict": true,
"skipLibCheck": false
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "**/*.test.ts", "**/*.spec.ts"]
}
Key option decisions:
declaration: true— emit.d.tsfor every source file; required for consumers to see your types.declarationMap: true— emit.d.ts.mapso IDEs can navigate from consumer call-sites back to your original.tssource.emitDeclarationOnly: true—tscwrites only declarations; a separate bundler step (esbuild, Rollup, tsup) produces the JavaScript. Prevents double-compilation and keeps build times low.verbatimModuleSyntax: true— enforcesimport typefor type-only imports, preventing runtime-invisible imports from appearing in emitted JavaScript and causing ESM/CJS interop issues.isolatedModules: true— each file must be compilable in isolation, which aligns with esbuild/SWC’s single-file transform model and surfaces errors that would otherwise appear only in consumers.skipLibCheck: false— validates third-party declarations; leave it off so broken upstream types don’t hide in your distribution.
Step-by-Step Implementation
Step 1 — Add declaration emit to tsconfig.json
Start from the canonical block above. If you already have a tsconfig.json, add or update these four options without touching the rest:
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "./dist/types"
}
}
Run tsc once to verify output:
npx tsc --project tsconfig.json
Expected directory structure after this step:
dist/
types/
index.d.ts
index.d.ts.map
utils.d.ts
utils.d.ts.map
HAZARD PREVENTION: If
dist/types/is empty after runningtsc, the most common cause isnoEmit: truestill present elsewhere in your config or in a config that extends yours. Search withgrep -r "noEmit" tsconfig*.json. RemovenoEmit: truefrom the config used for declaration generation; it overridesemitDeclarationOnlyand suppresses all file output silently.
Step 2 — Wire declarations into package.json exports
The exports field must include a types condition as the first key in each entry so TypeScript resolvers encounter it before the runtime conditions:
{
"name": "my-library",
"version": "1.0.0",
"main": "./dist/cjs/index.cjs",
"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.cjs",
"default": "./dist/esm/index.js"
}
},
"files": ["dist"]
}
The types condition must come before import, require, and default — TypeScript 4.7+ reads conditions in declaration order and stops at the first match.
HAZARD PREVENTION: A
typeskey at the top-level ofpackage.jsonis a legacy fallback used by TypeScript < 4.7 and tools that do not readexports. Keep it, but never rely on it as the sole declaration pointer for modern packages. Consumers using"moduleResolution": "Bundler"or"NodeNext"read only theexportsmap.
Step 3 — Flatten internal path aliases before emit
If your tsconfig.json uses baseUrl and paths (for example @internal/*), the compiler copies those alias references verbatim into emitted .d.ts files. Consumers who do not share your tsconfig cannot resolve these aliases and will see errors like Cannot find module '@internal/utils'.
The reliable fix is tsc-alias, which rewrites alias references to relative paths after tsc runs:
npm install --save-dev tsc-alias
Update your build script:
{
"scripts": {
"build:types": "tsc --project tsconfig.json && tsc-alias --project tsconfig.json"
}
}
tsc-alias reads the same paths config and replaces every alias with the computed relative path. See Path Mapping and Module Resolution Strategies for the complete alias-flattening workflow and edge cases with monorepo setups.
HAZARD PREVENTION:
tsc-aliasmust run aftertsc, not before. If you run them in the wrong order,tsc-aliaswill find no files to rewrite becausetschas not yet written the.d.tsoutput. Chain them with&&or use a sequential build tool step.
Step 4 — Choose a bundler-driven extraction workflow
For most libraries, one of three patterns covers declaration generation alongside JavaScript bundling:
tsup (recommended for simple APIs)
tsup wraps tsc declaration emit behind its dts: true option while using esbuild for JavaScript. It handles both ESM and CJS outputs in one configuration:
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true, // runs tsc under the hood; outputs .d.ts alongside JS
clean: true,
external: ['react', 'react-dom'],
sourcemap: true,
});
Expected output:
dist/
index.js (ESM)
index.cjs (CJS)
index.d.ts (shared declarations)
index.d.ts.map
HAZARD PREVENTION: tsup’s
dts: truespawns a separatetscprocess using your project’stsconfig.json. If that config hasnoEmit: true, declaration generation silently fails — the.d.tsfiles are never written. EnsurenoEmitis absent from the config tsup resolves, or pass--dts-resolveto bundle external type dependencies into the output declaration file.
Rollup + rollup-plugin-dts (for libraries with complex re-exports)
rollup-plugin-dts performs tree-shaking on declaration files and can merge multiple .d.ts files into a single bundled declaration — useful for libraries with large internal type graphs or conditional exports:
// rollup.config.mjs
import dts from 'rollup-plugin-dts';
import { defineConfig } from 'rollup';
export default defineConfig([
// JS build handled by a separate Rollup config or esbuild
{
input: 'dist/types/index.d.ts', // tsc output consumed here
output: { file: 'dist/index.d.ts', format: 'es' },
plugins: [dts()],
external: [/node_modules/],
},
]);
HAZARD PREVENTION: Rollup’s tree-shaking removes declarations it considers unreachable. If your library re-exports types that consumers need for type augmentation or
declare modulepatterns, mark those entry points inexternalor verify they appear in the rollup entry. Bundler type stripping that removes ambient declarations breaks global augmentations without any build error.
Parallel tsc + esbuild (for performance-critical monorepos)
esbuild’s --loader=ts strips types at sub-millisecond speed but does not write .d.ts files. Run tsc --emitDeclarationOnly in parallel for declarations and let esbuild handle JavaScript:
# package.json scripts
{
"scripts": {
"build": "run-p build:types build:esm build:cjs",
"build:types": "tsc --project tsconfig.json",
"build:esm": "esbuild src/index.ts --bundle=false --format=esm --outdir=dist/esm --platform=node",
"build:cjs": "esbuild src/index.ts --bundle=false --format=cjs --outdir=dist/cjs --platform=node --out-extension:.js=.cjs"
}
}
This approach appears in detail in Modern Build Tools: tsup, Rollup, and esbuild.
Step 5 — Validate before publishing
Validation must run in CI against the actual dist/ directory, not the source. See the full validation workflow in the next section.
Tooling Validation
Run these commands against the built output before every npm publish:
# 1. Type-check the generated declarations without re-emitting
tsc --noEmit --project tsconfig.types.json --skipLibCheck false
# 2. Check package.json exports correctness and field ordering
npx publint
# 3. Verify declaration files resolve correctly under all TypeScript moduleResolution modes
npx @arethetypeswrong/cli --pack .
tsconfig.types.json targets only the generated declarations:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"declaration": false,
"skipLibCheck": false,
"types": ["node"]
},
"include": ["dist/types/**/*.d.ts"]
}
Sample passing attw output:
┌─────────────────────────────────────────────────────┐
│ my-library │
├──────────────────────┬──────────────────────────────┤
│ "." (ESM) │ ✓ types resolve correctly │
│ "." (CJS) │ ✓ types resolve correctly │
└──────────────────────┴──────────────────────────────┘
Sample failing attw output (missing types condition):
┌─────────────────────────────────────────────────────┐
│ my-library │
├──────────────────────┬──────────────────────────────┤
│ "." (ESM) │ ✗ No types found │
└──────────────────────┴──────────────────────────────┘
A missing types condition in exports is the fix — add it as the first key per Step 2 above.
CI workflow:
# .github/workflows/validate-types.yml
name: Validate Types
on: [push, pull_request]
jobs:
type-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
- run: npx tsc --noEmit --project tsconfig.types.json --skipLibCheck false
- run: npx publint
- run: npx @arethetypeswrong/cli --pack .
HAZARD PREVENTION: Setting
skipLibCheck: truein the validation config defeats the purpose — it silences exactly the third-party type resolution errors that will break consumers. KeepskipLibCheck: falseintsconfig.types.jsoneven if your maintsconfig.jsonusestruefor developer convenience.
Compatibility Matrix
| TypeScript version | moduleResolution needed |
exports types condition |
verbatimModuleSyntax |
emitDeclarationOnly |
|---|---|---|---|---|
| 4.7–4.8 | NodeNext or Node16 |
Supported via typesVersions fallback |
Not available | Available |
| 4.9 | NodeNext |
Supported | Not available | Available |
| 5.0–5.1 | NodeNext or Bundler |
Supported | Available (replaces importsNotUsedAsValues) |
Available |
| 5.2+ | NodeNext or Bundler |
Supported; types condition read natively |
Available | Available |
| Node.js strip-types (22.6+) | N/A (runtime strips annotations) | N/A | Respected at runtime | No .d.ts produced |
Node.js --experimental-strip-types (Node 22.6+) and --strip-types (Node 23.0+) enable running .ts files directly, but they produce no .d.ts output. For library distribution, declaration emission through tsc remains mandatory regardless of runtime type stripping. See Generating Accurate .d.ts Files with TypeScript 5.4 for version-specific declaration emit changes.
| Bundler | Native .d.ts emit |
Recommended approach | Notes |
|---|---|---|---|
| tsup | Via internal tsc |
dts: true |
Fastest setup; use --dts-resolve for external type bundling |
| Rollup | Via rollup-plugin-dts |
Two-pass: tsc then rollup | Best for tree-shaken declaration bundles |
| esbuild | None | Parallel tsc --emitDeclarationOnly |
Fastest JS build; separate declaration step required |
| Vite (lib mode) | Via vite-plugin-dts |
Plugin wraps tsc |
Works; less control than direct tsc |
| Webpack | None | External tsc step |
Webpack is rarely the right choice for library declaration emit |
Further Reading
- Generating Accurate .d.ts Files with TypeScript 5.4 — TypeScript 5.4 tightened declaration emit rules around inferred return types and conditional generics; this page shows which patterns produce different output and how to update affected code.
Related
- Optimizing tsconfig.json for Library Distribution — maps every relevant compiler option to its effect on published output and explains which flags must match between your tsconfig and consumer tsconfigs.
- Path Mapping and Module Resolution Strategies — covers the full alias-flattening workflow and
tsc-aliasconfiguration so internal@scope/utilspaths never leak into published declarations. - Modern Build Tools: tsup, Rollup, and esbuild — compares each tool’s JavaScript bundling and declaration extraction capabilities for dual-format library builds.
- Mastering the package.json exports Field — explains condition key ordering, subpath exports, and how
typesinteracts withimport/requirefor TypeScript resolution. - Navigating the Dual Package Hazard — when ESM and CJS copies of a library coexist in a dependency graph, singleton state breaks; proper declaration paths are the first line of defence.