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.

TypeScript 5.4 Declaration Emit: Private Reference vs. Explicit Annotation Two side-by-side flows. Left: source exports a function whose return type references a private interface — emit fails with TS9006. Right: source exports the interface and annotates the function explicitly — emit succeeds and produces a .d.ts file. interface Config { … } // not exported export function init() { … } tsc --emitDeclarationOnly TS9006: private name 'Config' No .d.ts emitted — build fails Before fix export interface Config { … } export function init(): Config { … } tsc --emitDeclarationOnly Exit 0 — no errors dist/index.d.ts emitted ✓ After fix

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 attw may fail to resolve peer TypeScript installations. Install @arethetypeswrong/cli as a dev dependency and invoke it as ./node_modules/.bin/attw --pack . instead.

  • TypeScript strict vs noImplicitAny only: isolatedDeclarations does not require strict: true, but strict implies noImplicitAny, which surfaces most of the same annotation gaps earlier during normal compilation. Enabling strict first catches annotation issues before isolatedDeclarations turns 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: true in tsup.config.ts, or run tsc --emitDeclarationOnly as a separate step. Relying solely on esbuild without a separate tsc step produces no .d.ts files at all.

  • Vite library mode: Vite uses esbuild for transforms. Add vite-plugin-dts and configure it to use your tsconfig.json. The plugin runs tsc under the hood and respects isolatedDeclarations.

  • Path aliases in tsconfig paths: If you use TypeScript path aliases in your published package, aliases that remain in emitted .d.ts files will cause import resolution failures for consumers. Strip or rewrite aliases using tsc-alias or configure your bundler to resolve them at build time.

  • declaration without declarationDir: If declarationDir is not set, TypeScript emits .d.ts files alongside the .js files in outDir. Set declarationDir to a separate types/ subdirectory if you want to publish declarations separately from the runtime files, then update exports["."].types accordingly.

  • Node.js version compatibility: isolatedDeclarations is a TypeScript compiler feature with no Node.js version dependency, but the NodeNext module 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.


↑ Back to Declaration File Generation and Type Stripping