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:
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.
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.
tsup delegates declaration generation to a separate step, so declarations go missing in a few distinct ways. The first is simply not enabling dts — tsup emits JavaScript but no .d.ts. 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 share the property that the JavaScript builds successfully, so the failure is invisible until a consumer hits a 'could not find a declaration file' error. This is the late-failure pattern that plagues package publishing generally: your own tests import the source directly and never resolve the published types, so a missing or broken declaration surfaces only for a consumer resolving the packed package.
Resolution and configuration patch
Enable declaration emit and make sure the entry type-checks:
// 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.
Enable declaration emit, ensure the entry type-checks, and wire both flavors through exports:
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
clean: true,
});
{
"exports": {
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.mjs" },
"require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
}
}
}
Run tsc --noEmit before the build so a type error fails loudly rather than silently dropping declarations, and verify the packed types with attw.
CLI validation and debug commands
# 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 --noEmitbefore the bundler so a type error fails loudly instead of dropping declarations. - Add
@arethetypeswrong/clito CI to catch a missing or mis-wired declaration. - Assert
dist/index.d.tsexists in a post-build check. - Keep
dts: true(or a dedicatedtsc --emitDeclarationOnlystep) in the build script, not just locally.
- Run
tsc --noEmitbefore the bundler so a type error fails the build instead of dropping types. - Add
@arethetypeswrong/clito CI to catch a missing or mis-wired declaration. - Assert
dist/index.d.tsexists in a post-build check. - Keep
dts: true(or a dedicatedtsc --emitDeclarationOnlystep) in the build script, not just locally.
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.
{
"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.
- 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.
Splitting declaration emit for speed
For large libraries, tsup's dts option can make the build noticeably slower, because generating declarations adds a full type-checking pass on top of esbuild's transpile-only path. When build speed matters, splitting the declaration emit into a dedicated, cacheable step lets tsup handle only JavaScript while tsc handles types independently, which can be faster overall and lets the two run in parallel or cache separately in CI.
// 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"
}
}
The separate build:types step is a plain tsc invocation, so it caches on its own inputs and fails loudly on a type error rather than silently dropping declarations the way an integrated pass can. This split also makes the declaration step reusable — the same tsc configuration that emits declarations can drive editor tooling and type-checking — so the types are generated once, correctly, and validated as their own gate rather than as a side effect of the JavaScript build.
Wiring declarations through conditional exports
Emitting the declaration files is only half the job; the exports map must 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, each nested under its own types condition. A package that emits both declarations but points only a top-level types at one of them leaves the other format's consumers with wrong or missing types.
{
"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 @arethetypeswrong/cli reports a clean grid instead of a false CJS or a missing-types cell. This is why fixing 'missing declarations' is often two fixes: enabling the emit so the files exist, and wiring the exports so consumers actually resolve them. Verifying the packed package with attw confirms both — that the declarations were emitted and that every resolution mode finds the right one.
Making the type contract a CI gate
The reason a missing declaration reaches a consumer is that the local build looks green, so closing that gap means resolving your own package the way a consumer will, inside CI. @arethetypeswrong/cli packs the tarball and checks every module and resolution combination, so a dropped or mis-wired declaration fails your pipeline instead of a user's editor. Pairing it with an explicit file-existence assertion catches even a silently disabled dts.
- run: pnpm build
- run: pnpm exec attw --pack .
- run: test -f dist/index.d.ts && test -f dist/index.d.cts
The test -f assertions are a cheap backstop: even if a future config change disables declaration emit, the missing file fails the job. Together with attw, they turn 'the types are wrong' from a report you receive weeks later into a red build on the pull request that caused it. This is the same validate-the-artifact-not-the-source discipline that catches every other publish-time packaging bug — the types, like the JavaScript, must be checked against the packed tarball because that is what a consumer actually resolves.
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.
Why does my tsup build emit JavaScript but no .d.ts?
Either dts is not enabled, or the declaration pass hit a type error and logged it while still emitting the JavaScript. Run tsc --noEmit before the build so a type error fails loudly, and set dts: true (or a dedicated tsc --emitDeclarationOnly step).
Does generating declarations slow down my tsup build?
Yes — declaration generation adds a full type-checking pass on top of esbuild's transpile. For large libraries, split it into a dedicated cacheable tsc --emitDeclarationOnly step so the JavaScript build stays fast and the types cache and fail on their own inputs.
I emit .d.ts and .d.cts but consumers still get any — why?
The exports map probably isn't wiring the declarations per condition. Under node16 resolution the checker follows conditional exports, so nest a types condition in both the import (.d.ts) and require (.d.cts) branches. Verify with @arethetypeswrong/cli that every mode resolves a declaration.
How do I stop a missing declaration from reaching consumers?
Validate the packed package in CI: run @arethetypeswrong/cli --pack to resolve types across every module mode, plus a test -f dist/index.d.ts assertion as a backstop against a silently disabled dts. A dropped declaration then fails your build, not a consumer's editor.
Can esbuild emit type declarations directly?
No — esbuild is transpile-only and does not type-check or emit declarations. tsup pairs esbuild with a declaration step (dts: true), or you run tsc --emitDeclarationOnly alongside a raw esbuild build to produce the .d.ts and .d.cts files.
Related
- Generating Dual CJS/ESM Type Definitions — the dual-declaration recipe this fix depends on.
- Bundling and Build Tooling for Libraries — the overview for library build configuration.