Generating Accurate .d.ts Files with TypeScript 5.4
Fix TypeScript 5.4 declaration emit failures, resolve TS9006 private name errors and TS9008 isolatedDeclarations violations, generate declaration maps for dual ESM/CJS packages, and validate types with publint.
TypeScript 5.4 tightened declaration emit rules in ways that break packages that compiled without errors under 5.3 and earlier. The two most common failure surfaces are TS9006: Declaration emit for this file requires using private name 'X' and TS9008: Variable must have an explicit type annotation with --isolatedDeclarations — both block .d.ts output entirely, meaning consumers of your package receive no type information at all. This page walks through diagnosing each failure, applying the minimal fix, and verifying the output before publishing.
Root Cause Explanation
TypeScript 5.4 enforces that every symbol on a package’s public API surface carries a type annotation that can be emitted without reference to any private or unexported name. This is a direct consequence of the declaration file generation and type-stripping pipeline introduced alongside isolatedDeclarations support: the compiler must be able to produce each .d.ts file in isolation, without reading sibling files to infer types across module boundaries.
When a function’s inferred return type references an unexported interface, TypeScript 5.4 refuses to emit a .d.ts at all rather than emitting an inaccurate or incomplete declaration. The same strictness applies when --isolatedDeclarations is active: every exported binding needs an annotation that fully describes its shape without cross-file inference.
The underlying design principle is that declaration files must be deterministic — two independent builds of the same source should always produce byte-identical .d.ts output. Implicit inference from other modules breaks that guarantee.
Minimal Reproduction
The smallest setup that triggers both TS9006 and TS9008 — useful for isolating the issue in a fresh directory before applying the fix to a larger codebase.
package.json
{
"name": "my-lib",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build:types": "tsc --project tsconfig.json --emitDeclarationOnly --noEmitOnError"
},
"devDependencies": {
"typescript": "^5.4.0"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"isolatedDeclarations": true,
"verbatimModuleSyntax": true,
"outDir": "./dist",
"strict": true
},
"include": ["src"]
}
src/index.ts — broken version (triggers TS9006 + TS9008)
// TS9006: Config is private — not exported
interface Config {
timeout: number;
retries: number;
}
// TS9008: return type inferred from Config, which is private
export function createClient(opts: Config) {
return { ...opts, createdAt: Date.now() };
}
// TS9008: no explicit type annotation on exported const
export const DEFAULT_TIMEOUT = 3000;
Running npm run build:types against this file produces:
src/index.ts(8,17): error TS9006: Declaration emit for this file requires
using private name 'Config'. An explicit type annotation may unblock
declaration emit.
src/index.ts(13,14): error TS9008: Variable must have an explicit type
annotation with --isolatedDeclarations.
Step-by-Step Fix
1. Export every type referenced in a public signature
The TS9006 error fires because Config is used in the parameter of an exported function but is not itself exported. TypeScript 5.4 cannot reference it in the .d.ts without leaking a private name.
Before:
interface Config {
timeout: number;
retries: number;
}
export function createClient(opts: Config) {
return { ...opts, createdAt: Date.now() };
}
After:
export interface Config {
timeout: number;
retries: number;
}
export function createClient(opts: Config): Config & { createdAt: number } {
return { ...opts, createdAt: Date.now() };
}
Both the parameter type and the return type now refer only to exported names, so the compiler can produce the declaration without any private-name references.
2. Annotate all exported constants and variables
The TS9008 error targets exported const, let, and var declarations whose type would otherwise be inferred. With --isolatedDeclarations, inference across files is disabled at emit time.
Before:
export const DEFAULT_TIMEOUT = 3000;
export const ENDPOINTS = {
prod: "https://api.example.com",
staging: "https://staging.example.com",
};
After:
export const DEFAULT_TIMEOUT: number = 3000;
export const ENDPOINTS: Record<"prod" | "staging", string> = {
prod: "https://api.example.com",
staging: "https://staging.example.com",
};
Primitive literals like 3000 infer as 3000 (a literal type) rather than number under strict, so an explicit annotation is often needed to widen the type to what consumers actually expect.
3. Fix type-only re-exports under verbatimModuleSyntax
When verbatimModuleSyntax is enabled alongside isolatedDeclarations, re-exporting a type with plain export { Foo } triggers a type-stripping error because the compiler cannot determine at emit time whether Foo is a value or a type without reading the source file.
Before:
export { Config } from "./types.js";
After:
export type { Config } from "./types.js";
This applies to both export and import statements: any symbol that is exclusively a type must use the type keyword modifier. See declaration file generation and type stripping for the full mechanics of the type-stripping pipeline and why this matters for dual ESM/CJS builds.
4. Enable declaration maps for source navigation
Add declarationMap: true to tsconfig.json so that editors can navigate from a consumer’s usage of your types directly to the original .ts source rather than the generated .d.ts:
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"isolatedDeclarations": true,
"verbatimModuleSyntax": true,
"outDir": "./dist",
"strict": true
}
}
This generates a dist/index.d.ts.map alongside each declaration file. It has no effect on the emitted .js files, so there is no runtime cost.
5. Wire the types export condition correctly
The exports field in package.json must point to the emitted .d.ts under a "types" condition, and "types" must appear before "import" / "require" / "default" so that TypeScript resolves declarations before falling back to the implementation:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
}
}
For packages shipping both ESM and CJS outputs, add separate "require" and "import" conditions and pair each with its own declaration file:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
If the CJS and ESM outputs have meaningfully different shapes, generate separate index.d.ts and index.d.cts files and point each condition to the matching declaration, to avoid the dual-package hazard that arises when a package’s CJS and ESM instances diverge at runtime.
Verification Command
Run all three validation steps in sequence. Exit codes are non-zero on failure, so any CI pipeline using && chains will halt at the first problem.
# 1. Confirm TypeScript emits cleanly with no errors
npx tsc --project tsconfig.json --emitDeclarationOnly --noEmitOnError --listEmittedFiles
# 2. Check package.json exports alignment with emitted files
npx publint
# 3. Verify each export condition resolves to the correct .d.ts
npx attw --pack .
Expected output (passing):
TSFILE: dist/index.d.ts
TSFILE: dist/index.d.ts.map
TSFILE: dist/index.js
✓ No issues found with package-publishing.com
┌─────────────────────────────┬──────────┐
│ entrypoint │ status │
├─────────────────────────────┼──────────┤
│ "." (import, types) │ ✓ OK │
│ "." (require, types) │ ✓ OK │
└─────────────────────────────┴──────────┘
If publint reports "types" condition must come before "import", reorder the condition keys in package.json as shown in step 5. If attw reports Resolution failed, the outDir path in tsconfig.json does not match the path in the exports field.
Edge Cases and Gotchas
-
pnpm with
hoist=false: In strict pnpm workspaces where hoisting is disabled,npx attwmay fail to resolve peer TypeScript installations. Install@arethetypeswrong/clias a dev dependency and invoke it as./node_modules/.bin/attw --pack .instead. -
TypeScript
strictvsnoImplicitAnyonly:isolatedDeclarationsdoes not requirestrict: true, butstrictimpliesnoImplicitAny, which surfaces most of the same annotation gaps earlier during normal compilation. Enablingstrictfirst catches annotation issues beforeisolatedDeclarationsturns them into hard emit failures. -
tsup and esbuild type emit: Tools like tsup, Rollup, and esbuild do not call the TypeScript compiler for type emit by default — they only transpile JavaScript. Pass
dts: trueintsup.config.ts, or runtsc --emitDeclarationOnlyas a separate step. Relying solely onesbuildwithout a separatetscstep produces no.d.tsfiles at all. -
Vite library mode: Vite uses esbuild for transforms. Add
vite-plugin-dtsand configure it to use yourtsconfig.json. The plugin runstscunder the hood and respectsisolatedDeclarations. -
Path aliases in tsconfig
paths: If you use TypeScript path aliases in your published package, aliases that remain in emitted.d.tsfiles will cause import resolution failures for consumers. Strip or rewrite aliases usingtsc-aliasor configure your bundler to resolve them at build time. -
declarationwithoutdeclarationDir: IfdeclarationDiris not set, TypeScript emits.d.tsfiles alongside the.jsfiles inoutDir. SetdeclarationDirto a separatetypes/subdirectory if you want to publish declarations separately from the runtime files, then updateexports["."].typesaccordingly. -
Node.js version compatibility:
isolatedDeclarationsis a TypeScript compiler feature with no Node.js version dependency, but theNodeNextmodule resolution mode requires Node.js 12+ and TypeScript 4.7+. TypeScript 5.4 itself requires Node.js 16+.
FAQ
Why does TS9006 still appear after I added an explicit return type?
TS9006 fires when any type in the function’s signature — including parameter types, generic constraints, or the return type — references an unexported name, not only the return type. Audit every type in the function signature and ensure each one is exported from the same file or re-exported through the package’s public barrel.
Do I need declarationMap alongside declaration in tsconfig?
declarationMap is optional for consumers but valuable for library authors. It lets editors jump from a consumer’s usage directly to the original .ts source. Without it, “Go to Definition” resolves to the generated .d.ts file, which lacks comments and context. Enable it alongside sourceMap for the best author and consumer experience.
Can isolatedDeclarations break a build that previously compiled fine?
Yes. isolatedDeclarations is stricter than standard declaration emit because it disables cross-file type inference at emit time. Any exported symbol whose type was previously inferred from a type in another module will now require an explicit annotation. Introduce it incrementally: enable the flag, let the compiler report all errors, then fix them file by file.
Why does verbatimModuleSyntax cause errors in .d.ts files?
With verbatimModuleSyntax, TypeScript preserves import and export syntax verbatim. A plain export { Foo } where Foo is a type cannot be safely stripped by the compiler without reading the source of Foo. Switching to export type { Foo } signals to the compiler that the export is type-only and can be erased cleanly during emit.
How do I validate .d.ts files after fixing emit errors?
Run npx publint to check package.json exports alignment, then npx attw --pack . to confirm that each export condition resolves to the correct declaration file. publint catches metadata mismatches; attw catches TypeScript resolution failures that publint does not check.
Related
- Declaration File Generation and Type Stripping — the parent cluster covering the full type-stripping pipeline,
emitDeclarationOnly, and how TypeScript handles type vs. value imports across module formats. - Using tsup to Bundle Dual ESM and CJS Outputs — how to configure tsup’s
dtsoption alongside dual output so declaration files are generated for both module formats in a single build step. - Handling TypeScript Path Aliases in Published Packages — what happens when
pathsaliases survive into emitted.d.tsfiles and how to strip them before publishing.