Back to core workflows Fix dependency resolution Tune package metadata Jump to monorepo patterns

Fixing tsup Output Missing .d.ts Type Declarations

Your tsup build produces .mjs and .cjs files but no .d.ts, so consumers get a 'could not find a declaration file' error. This page covers the exact symptom, why declaration emit is skipped, and the configuration that restores it.

Exact symptoms and error messages

The JavaScript builds, but the dist folder has no declaration files, and consumers hit:

Exact symptoms and error messages The JavaScript builds, but the dist folder has no declaration files, and consumers hit: Exact symptoms and error messages The JavaScript builds, but the dist folder has no declaration files, and consumers hit:
Exact symptoms and error messages — the core idea of this section at a glance.
Could not find a declaration file for module 'your-lib'.
'/node_modules/your-lib/dist/index.mjs' implicitly has an 'any' type.

ls dist shows index.mjs and index.cjs but no index.d.ts or index.d.cts.

Root cause analysis

tsup delegates declaration generation to a separate step; if dts is not enabled, or if tsc cannot type-check your entry (a type error, a missing tsconfig, or skipLibCheck interactions), tsup emits JavaScript and silently omits the declarations. Because the runtime files exist, the build looks successful — the failure only surfaces in a consumer, exactly the class of problem described in TypeScript Declaration Publishing.

Root cause analysis tsup delegates declaration generation to a separate step; if dts is not enabled, or if tsc cannot type-check your entry Root cause analysis tsup delegates declaration generation to a separate step; if dts is not enabled, or if tsc cannot type-check your entry (a type error, a missing tsconfig, or sk
Root cause analysis — the core idea of this section at a glance.

There are three distinct ways declarations go missing, and they need different fixes. The first is simply not enabling dts at all. The second is a type error in the entry graph: tsup's declaration pass runs a real type-check, and when it fails it logs the error but still emits the JavaScript, so a green-looking build ships without types. The third is a monorepo path issue — an unresolved paths alias or a project reference the declaration pass cannot follow — which produces partial or empty declarations. All three surface only in a consumer, the late-failure pattern that TypeScript Declaration Publishing exists to prevent.

Declaration bundling adds a fourth wrinkle. Even when .d.ts files emit, if they reference an internal type from a deep path that the files allowlist excludes, the consumer's checker follows a dangling import and reports the type as any. That is why verifying with a resolver that mimics the consumer — @arethetypeswrong/cli — catches problems a local tsc against your own source never will.

Resolution and configuration patch

Enable declaration emit and make sure the entry type-checks:

Resolution and configuration patch Enable declaration emit and make sure the entry type-checks: Resolution and configuration patch Enable declaration emit and make sure the entry type-checks:
Resolution and configuration patch — the core idea of this section at a glance.
// tsup.config.ts
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],
  dts: true,
  clean: true,
});

Then wire both declaration flavors through exports so each condition resolves its own types, as covered in Generating Dual CJS/ESM Type Definitions.

For large libraries where the extra type-check makes tsup slow, split declaration emit into a dedicated, cacheable step and let tsup handle only JavaScript:

// package.json
{
  "scripts": {
    "build:js": "tsup src/index.ts --format esm,cjs",
    "build:types": "tsc --emitDeclarationOnly --declaration --outDir dist",
    "build": "npm run build:js && npm run build:types"
  }
}

This keeps the transpile fast and makes the declaration step independently cacheable in CI, while still producing the .d.ts files the exports map points at.

CLI validation and debug commands

CLI validation and debug commands CLI validation and debug commands in production JavaScript package workflows. CLI validation and debug commands CLI validation and debug commands in production JavaScript package workflows.
CLI validation and debug commands — the core idea of this section at a glance.
# Confirm declarations now emit
pnpm build && ls dist/*.d.*
# Prove the types resolve the way a consumer's tooling will
pnpm exec attw --pack .
# Catch a type error that would silently skip dts
pnpm exec tsc --noEmit

Prevention and CI guardrails

  • Run tsc --noEmit before the bundler so a type error fails loudly instead of dropping declarations.
  • Add @arethetypeswrong/cli to CI to catch a missing or mis-wired declaration.
  • Assert dist/index.d.ts exists in a post-build check.
  • Keep dts: true (or a dedicated tsc --emitDeclarationOnly step) in the build script, not just locally.
Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows. Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows.
Prevention and CI guardrails — the core idea of this section at a glance.

Wiring declarations through conditional exports

Emitting the files is only half the job; the exports map has to point each condition at the matching declaration, or a consumer under node16/nodenext resolution still gets any. Under those modes the type checker follows the same conditional resolution as the runtime, so the import branch needs a .d.ts and the require branch needs a .d.cts.

Types per condition Each exports condition points at its own declaration file. import → .d.ts ESM consumers require → .d.cts CJS consumers attw green matrix resolves
Point the import branch at .d.ts and the require branch at .d.cts.
{
  "exports": {
    ".": {
      "import": { "types": "./dist/index.d.ts", "default": "./dist/index.mjs" },
      "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
    }
  }
}

With the nested types conditions in place, tsc resolves the format-appropriate declaration for each import style, and attw reports a clean matrix instead of a false CJS or no types cell.

Making the type contract a CI gate

The reason a missing declaration reaches a consumer is that the local build looks green. Close that gap by resolving your own package the way a consumer will, inside CI. @arethetypeswrong/cli packs the tarball and checks every module/resolution combination, so a dropped or mis-wired declaration fails your pipeline instead of a user's editor.

Type CI gate Build, pack, resolve types as a consumer, assert files exist. build + pack tarball as shipped attw --pack resolve every mode assert .d.ts fail on missing
attw plus file assertions catch a dropped declaration before it reaches a consumer.
- run: pnpm build
- run: pnpm exec attw --pack .
- run: test -f dist/index.d.ts && test -f dist/index.d.cts

The explicit test -f assertions are a cheap backstop: even if a future config change silently disables dts, the missing file fails the job. Together they turn 'the types are wrong' from a report you get weeks later into a red build on the PR that caused it.

Frequently Asked Questions

Does dts: true slow the build down?

Yes, declaration generation adds a type-checking pass, which is slower than esbuild's transpile-only path. For large libraries, a separate cached tsc --emitDeclarationOnly step can be faster in CI.

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 declaration next to its .cjs file.

Why does the JavaScript build succeed but declarations silently vanish?

Because tsup's declaration pass is a separate type-check. When it hits a type error it logs it but still emits the transpiled JS, so the build exits green with no .d.ts. Run tsc --noEmit first so the type error fails the build loudly.

Can I emit one declaration file for both ESM and CJS?

Only under bundler/node10 resolution. Under node16/nodenext the checker follows conditional exports, so the require branch needs its own .d.cts. Emitting both is the safe default for a dual-published package.

Related

Bundling and Build Tooling for Libraries