Generating TypeScript Declarations with vite-plugin-dts
Vite’s library mode bundles your JavaScript, but esbuild — which Vite uses to strip types — cannot emit .d.ts files, so a published library ships without types unless you add them. vite-plugin-dts runs the TypeScript compiler alongside the build to emit and optionally bundle declarations. This guide wires it correctly so consumers get type hints. It completes the publishing story in Vite library mode and package bundling; the parent overview is Vite Configuration Ecosystem.
The reason this problem exists at all is a division of labor inside Vite’s build. When you run a library build, Vite hands your source to esbuild for transpilation because esbuild is roughly twenty to a hundred times faster than tsc at turning TypeScript into JavaScript. That speed comes from a deliberate simplification: esbuild does no type checking and builds no type graph. It parses each file, deletes the type annotations, and emits JavaScript. It never learns that greet takes a User, so it cannot write that fact into a declaration file. tsc is the only tool in the standard toolchain that carries the full type information through to an emit phase, and it is the tool esbuild was designed to avoid. So the fast path that makes library builds pleasant is exactly the path that discards the types you need to publish.
Without the fix, the failure is quiet and it lands on your consumers, not on you. Your dist/index.js is correct, your tests pass, npm publish succeeds, and nothing warns you. A consumer installs the package, imports greet, and their editor infers the parameter and return type as any. Autocomplete is empty, refactors do not follow the call site, and a wrong argument compiles without complaint until it fails at runtime. Because the symptom appears only in someone else’s project, it routinely survives review and ships. The job of vite-plugin-dts is to run tsc in emit-only mode next to the esbuild build, capture the declarations esbuild threw away, and — with rollupTypes — flatten them into one file whose shape matches your public API rather than your source tree.
Where this sits in the pipeline matters for reasoning about failures later. The plugin does not modify the JavaScript output and the JavaScript build does not depend on it; the two run as parallel outputs from the same entry. That independence is why a broken type can still produce a shippable-looking dist/ — the JS half never asked the type half whether it succeeded — and it is why the exports map, covered below, has to point at two artifacts that were produced by two different compilers.
tsc for the declarations esbuild cannot produce, in parallel with the JS build.Prerequisites & reproducible setup
# Vite 5.4.x, Node 20+, vite-plugin-dts 4.x
npm create vite@latest lib-dts-demo -- --template react-ts
cd lib-dts-demo && npm install
npm install --save-dev vite-plugin-dts
# A library entry with exported types.
printf "export interface User { id: number }\nexport const greet = (u: User) => u.id;\n" > src/index.ts
You need a tsconfig.json with declaration: true intent (the plugin drives tsc), and a Vite build.lib config so the build runs in library mode. The two requirements are independent and both are load-bearing: build.lib tells Vite to emit a library rather than an app with an HTML entry, and the plugin is what runs the compiler that produces declarations. Adding one without the other gets you a half-build — library mode alone ships JavaScript with no types, and the plugin without library mode has no coherent entry to describe.
The tsconfig.json the plugin reads is the same one your editor and tsc --noEmit read, which is deliberate: the declarations are only as correct as the compiler options that produced the JavaScript your consumers type-check against. A few options change the emitted .d.ts in ways worth knowing before the first build. declaration: true is implied by the plugin but harmless to set explicitly. declarationMap: true emits .d.ts.map files so a consumer’s “go to definition” jumps into your original .ts source rather than the generated declaration — useful when you ship source alongside the package, dead weight when you do not. stripInternal: true drops any symbol tagged with a /** @internal */ JSDoc comment from the output, which is the cleanest way to keep helper types out of a published surface without moving them to a separate file. compilerOptions.paths aliases are the common surprise here: the plugin resolves them through the same TypeScript resolver, but if the alias points outside rootDir the emitted paths can escape dist, which the leaked-type gotcha below revisits.
// tsconfig.json — the options that shape the emitted declarations. TS 5.4.x
{
"compilerOptions": {
"declaration": true, // emit .d.ts (implied by the plugin)
"declarationMap": true, // emit .d.ts.map for go-to-source
"stripInternal": true, // drop /** @internal */ symbols from output
"emitDeclarationOnly": false // JS still comes from esbuild, not tsc
},
"include": ["src"]
}
How it works under the hood
vite-plugin-dts hooks into Rollup’s build lifecycle, not esbuild’s transform. On each processed module it asks the TypeScript language service for that file’s emitted declaration, accumulating a map of source path to .d.ts text in memory as the build walks the module graph. It does not shell out to tsc as a subprocess; it drives the compiler API in-process so it can share the parsed program and avoid a second full type-check pass. This is why the plugin’s cost scales with the size of your type graph rather than with the number of output formats — building both ES and CJS does not double the declaration work, because declarations are emitted once from the source, not once per format.
The interesting phase is rollupTypes. When it is off, the plugin writes the accumulated declarations to dist mirroring your source layout: src/index.ts becomes dist/index.d.ts, src/util/math.ts becomes dist/util/math.d.ts, and cross-file import type references stay as relative imports between those files. When rollupTypes is on, the plugin runs a second flattening pass built on the API Extractor model: it starts from the declaration entry, follows every exported and transitively-referenced type, inlines them into a single index.d.ts, renames symbols that would collide once merged into one file, and drops anything not reachable from an export. The output is one file whose surface is your public API and nothing else. The trade-off is that this pass needs an unambiguous single entry point to walk from — the reason the multi-entry gotcha below exists — and it is the slowest part of the plugin because it re-resolves the full reachable type graph.
Ordering relative to the JavaScript build is worth internalizing. The declaration emit runs in Rollup’s generateBundle/writeBundle phase, after modules are transformed, so it observes the same module set esbuild transpiled. But it reads types from your source on disk, not from esbuild’s stripped output, so any transform plugin that rewrites JavaScript without updating the corresponding types is invisible to it. If you use a Babel or SWC plugin that injects runtime code with a type shape, the declarations will not reflect that injection unless the types exist in source. Treat the declaration output as a function of your .ts files and tsconfig, never of the JavaScript pipeline.
Diagnosis workflow
- Confirm types are missing. After a plain library build,
dist/hasindex.jsbut noindex.d.ts, so consumers seeany. Confirm it directly withls dist/*.d.ts— an empty result means no declarations were emitted at all, which points at a missing plugin or a build that never entered library mode. If the file exists but consumers still seeany, the problem is the pointer in step 3, not emission, and the two failure modes are worth separating before you change any config. - Decide bundled vs per-file declarations. One rolled-up
index.d.tsis cleaner for consumers and hides your internal file layout, so a refactor that moves a helper does not become a breaking change to the type surface; per-file mirrors your source tree and preservesdeclarationMapgo-to-source across files. Choose bundled for a single-entry library and per-file for a multi-entry one — the choice is forced by whetherrollupTypeshas an unambiguous entry to flatten from, not by taste. - Check the
exportstypes condition. Even correct.d.tsfiles are invisible unlesspackage.jsonpointstypes/theexportstypescondition at them. The tell here is that emission succeeded — the file is on disk — but the consumer’s resolver never looks at it. Verify the pointer withnode -e "console.log(require('./package.json').exports)"and confirm thetypeskey resolves to the filelsjust found; a path typo or a staledist/layout is the usual cause.
.d.ts files nobody is pointed at do nothing.Annotated solution config
Two files do the work: vite.config.ts adds the plugin and puts the build in library mode, and package.json points consumers at what was emitted. Neither is optional and the mistakes cluster in the seams between them — a plugin option that assumes a single entry, an exports map whose condition order defeats the resolver. Read the two blocks below as one unit.
// vite.config.ts — Vite 5.4.x, library mode + declarations.
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [
dts({
// Roll all declarations into a single entry file for a clean API surface.
rollupTypes: true,
// Fail the build if a type error would ship broken declarations.
insertTypesEntry: true,
}),
],
build: {
lib: {
entry: 'src/index.ts',
formats: ['es', 'cjs'],
fileName: (fmt) => `index.${fmt === 'es' ? 'js' : 'cjs'}`,
},
},
});
// package.json — point consumers at the emitted declarations.
{
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts", // MUST be first
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
The insertTypesEntry option and the exports order solve two different halves of the same resolution. insertTypesEntry guarantees the plugin writes a top-level index.d.ts that re-exports the rolled-up types, so there is a stable file for the types condition to name regardless of how the internal flattening laid things out. The exports order matters because of how TypeScript resolves a subpath: it walks the condition object top to bottom and takes the first key it recognizes. TypeScript recognizes types, import, and require. If import comes first, TypeScript matches import, resolves the JavaScript file, finds no adjacent declaration it was told to use, and falls back to inferring any — it never reads further down to types. Put types first and the resolver matches it immediately and reads your declarations. This is why “correct file, wrong order” produces exactly the same consumer symptom as “no file at all,” and why the fix is a reordering rather than a rebuild.
A related subtlety: modern TypeScript with moduleResolution: "bundler" or "node16" reads the exports map, but older consumers on classic resolution read only the top-level types field. Shipping both the top-level types and the exports types condition is not redundant — it is what makes the package resolve correctly across the resolution modes your consumers actually run.
types condition must be first in the exports map or TypeScript resolves the JS and ignores the declarations.Verification
# Vite 5.4.x — build and confirm declarations exist and are referenced.
npx vite build
ls dist/index.d.ts # exists
# In a consumer, hover greet() — the parameter should be typed User, not any.
npx tsc --noEmit # a wrong call is a type error
After the build, dist/index.d.ts exists, and a consumer importing the library gets greet(u: User) with full type hints instead of any. Passing a wrong argument type is now a compile error in the consumer.
The ls check confirms emission; it does not confirm resolution, and those are the two failures the diagnosis workflow separated. To confirm resolution the way a consumer’s toolchain will, use the compiler’s own resolver rather than trusting your editor, which may be reading the source in your monorepo instead of the published dist. The command below asks TypeScript to trace exactly which file it resolves the package’s types to — if it prints a path under dist/, the pointer is correct; if it prints nothing or a .ts source path, the exports order or the types field is wrong.
# TS 5.4.x — trace where a consumer's compiler resolves the types to.
npx tsc --traceResolution --noEmit 2>&1 | grep 'index.d.ts'
# A line naming dist/index.d.ts proves the exports pointer works.
# For belt-and-suspenders, arethetypeswrong catches condition-order bugs:
npx @arethetypeswrong/cli --pack . # flags FalseESM, resolution gaps
@arethetypeswrong/cli is worth running once before every publish: it simulates resolution under each module mode and each condition and reports the exact combinations where a consumer would fall back to any, which is faster than discovering it from a downstream bug report. Treat a clean run as the real acceptance test for this whole guide.
any.Gotchas & edge cases
types condition placed after import is silently ignored — order is the most common miss.typescondition order. Inexports,typesmust precedeimport/require; placed after, TypeScript never sees it. The symptom is the cruelest one here because emission looks perfect:dist/index.d.tsis present and correct, yet consumers getany. The root cause is the top-to-bottom condition match described above —importwins,typesis never reached. The fix is to movetypesto the first key. Confirm it witharethetypeswrong, which reports this specific case rather than making you infer it from a downstream editor.rollupTypesneeds one entry. Rolling up multiple entries into one file is ambiguous; use per-file declarations for a multi-entry library. The symptom is either an error from the flattening pass or a rolled-up file that silently drops one entry’s exports. The root cause is that the flattener walks from a single declaration entry and cannot merge two disjoint public surfaces into one file without collisions. The fix is to turnrollupTypesoff and let the plugin emit one.d.tsper entry, each named by its ownexportssubpath. Confirm by checking that every entry in yourexportsmap has a matching declaration on disk.- Broken declarations still emit. A type error can produce invalid
.d.ts; runtsc --noEmitin CI so broken types fail the build. The symptom is a green build that ships declarations referencing a type that does not resolve, so the consumer’s compiler errors on your package. The root cause is the parallel-output independence from the lead: the esbuild JS build never consults the type-check result, so a type error does not failvite build. The fix is a separatetsc --noEmitgate that runs before publish. Confirm by deliberately introducing a type error and checking that the gate — not the Vite build — is what goes red. - Leaked dependency types. A public type importing from a dependency can emit an unresolved path; bundle it with
rollupTypesor re-declare it. The symptom is a declaration containingimport('../../node_modules/...')or a bare specifier the consumer has not installed. The root cause is that a public type referencing an internal or dev-only dependency drags that dependency into your surface. The fix is either to inline the referenced type viarollupTypes, move the dependency todependenciesso consumers install it too, or re-declare a minimal local copy of the type you actually expose. Confirm by grepping the emittedindex.d.tsfornode_modulesand for any specifier not in your publisheddependencies.
Multi-entry libraries
When a package exposes more than one entry — a main bundle plus a ./server or ./cli subpath — rollupTypes no longer applies, because there is no single surface to flatten. The pattern is to define one lib.entry per subpath and let the plugin emit one declaration file per entry, then give each subpath its own types-first condition in exports. The plugin’s per-file mode already mirrors the entries; the work is keeping the exports map in sync so every JavaScript entry has a declaration sitting next to it.
// vite.config.ts — multi-entry: per-file declarations, no rollupTypes. Vite 5.4.x
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [
dts({ insertTypesEntry: true }), // rollupTypes omitted: emit one .d.ts per entry
],
build: {
lib: {
entry: {
index: 'src/index.ts',
server: 'src/server.ts', // becomes dist/server.d.ts + dist/server.js
},
formats: ['es'],
},
},
});
Each entry then needs its own condition block — "./server" pointing types at ./dist/server.d.ts before its import — or the subpath resolves to any even though the file exists. This is the same ordering rule as the single-entry case, applied once per subpath, and it is the most common place a multi-entry package half-works: the main entry is typed, a secondary one is not, because only its condition block was forgotten.
CI integration
The right place to enforce all of this is a build script that runs the type gate and the resolution check on every publish, so neither can regress silently. Because the Vite build does not fail on type errors, the gate has to be an explicit step, and it should run before vite build so a broken type stops the pipeline before any artifact is produced. Ordering the steps this way also keeps CI logs legible: a red tsc line is a type error, a red vite build line is a bundling error, and they never mask each other.
// package.json — scripts that make the gate impossible to skip. Vite 5.4.x
{
"scripts": {
"typecheck": "tsc --noEmit",
"build": "npm run typecheck && vite build",
"prepublishOnly": "npm run build && attw --pack ."
}
}
prepublishOnly runs automatically on npm publish, so wiring the resolution check there means a package with a broken exports order cannot be published even by hand. In a monorepo, run typecheck at the workspace root so a type change in one package that breaks another’s public declarations is caught in the same job, not on the next consumer’s build.
When not to use this
The plugin is the right tool when you author in TypeScript and publish a library. It is the wrong tool in a few cases worth naming so you do not reach for it reflexively. If you hand-write .d.ts files — common for packages that wrap a C addon or ship pre-existing declarations — the plugin has nothing to generate and will only fight your hand-authored types; point exports at the files you wrote and skip the plugin. If you build an application rather than a library, you have no public type surface and no consumer, so declarations are pure cost. And if your build is already orchestrated by tsc in a project-references setup that emits declarations itself, adding the plugin duplicates that emit; let the compiler you already run own the declarations and use Vite only for the JavaScript bundle.
Comparison with running tsc directly
The alternative to the plugin is running tsc --emitDeclarationOnly as a separate build step. It works and it is one fewer dependency, but it emits per-file declarations mirroring your source and gives you no flattening, so a multi-file library ships its internal layout as its public type surface and every file move risks a breaking change to consumers. It also runs as a second full pass with no sharing of the program Vite already parsed, so it is slower on large graphs. The plugin’s value over raw tsc is precisely the rollupTypes flattening and the in-process program sharing; if you do not need flattening and your library is a single file, raw tsc --emitDeclarationOnly is a defensible, dependency-light choice. For anything multi-file with a public API narrower than its source tree, the plugin’s flattening is the deciding factor.
Related
- Vite library mode and package bundling — the parent cluster on publishing a Vite library.
- Externalizing peer dependencies in Vite library mode — the other half of a correct library build.
- Avoiding the dual-package hazard — the exports-map concerns these types share.