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.

esbuild bundles JS; vite-plugin-dts emits the declarations Vite's library build uses esbuild to strip types and emit JavaScript but cannot produce d.ts files, so vite-plugin-dts runs tsc in parallel to emit declarations and roll them into one file the package's types condition points to. Two parallel outputs: JS and .d.ts src/index.tslibrary source esbuild → index.jstypes stripped tsc → index.d.tsvite-plugin-dts
Figure: the plugin runs 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"]
}
Library mode plus the dts plugin are both required Emitting declarations needs Vite running in library mode via build.lib and the vite-plugin-dts plugin added to the plugins array, because library mode alone still produces no d.ts files. build.liblibrary mode vite-plugin-dtsemits .d.ts library mode alone still ships no types
Figure: library mode produces the JS; the plugin is what adds the declarations.

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

  1. Confirm types are missing. After a plain library build, dist/ has index.js but no index.d.ts, so consumers see any. Confirm it directly with ls 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 see any, the problem is the pointer in step 3, not emission, and the two failure modes are worth separating before you change any config.
  2. Decide bundled vs per-file declarations. One rolled-up index.d.ts is 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 preserves declarationMap go-to-source across files. Choose bundled for a single-entry library and per-file for a multi-entry one — the choice is forced by whether rollupTypes has an unambiguous entry to flatten from, not by taste.
  3. Check the exports types condition. Even correct .d.ts files are invisible unless package.json points types/the exports types condition 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 with node -e "console.log(require('./package.json').exports)" and confirm the types key resolves to the file ls just found; a path typo or a stale dist/ layout is the usual cause.
Emitting types is not enough without the exports pointer A library can emit correct d.ts files yet still show any to consumers if package.json does not point its types field or the exports types condition at the declaration file, so both emission and the pointer are required. emit .d.tsplugin does thisnecessary exports types conditionpoints consumers at themalso necessary
Figure: emitting and pointing are separate steps — correct .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.

rollupTypes bundles, the exports condition points rollupTypes rolls all declarations into a single index.d.ts and insertTypesEntry adds the entry, while the package.json exports map lists the types condition first so TypeScript consumers resolve the declarations before the JS. rollupTypes: trueone index.d.tsclean surface exports types firstresolved before JSorder matters bundle the types, then point at them first
Figure: the 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.

Consumer sees real types, not any Verification is that the dist declaration file exists and a consumer sees the exported function typed with its real parameter type rather than any, so a wrong argument becomes a compile error. index.d.ts existsemitted greet(u: User)not any the consumer's editor is the real test
Figure: the proof is in the consumer — a typed parameter instead of any.

Gotchas & edge cases

Four declaration-emit edge cases The types condition placed after import in exports is ignored; rollupTypes needs a single entry so multiple entries emit per-file; a type error in source can still emit broken declarations without a check; and importing a type from a dependency without bundling can leak an unresolved path. types after import→ put types first rollupTypes + multi-entry→ one entry to roll up broken declarations→ type-check in CI leaked dependency type→ bundle or declare it
Figure: a types condition placed after import is silently ignored — order is the most common miss.
  • types condition order. In exports, types must precede import/require; placed after, TypeScript never sees it. The symptom is the cruelest one here because emission looks perfect: dist/index.d.ts is present and correct, yet consumers get any. The root cause is the top-to-bottom condition match described above — import wins, types is never reached. The fix is to move types to the first key. Confirm it with arethetypeswrong, which reports this specific case rather than making you infer it from a downstream editor.
  • rollupTypes needs 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 turn rollupTypes off and let the plugin emit one .d.ts per entry, each named by its own exports subpath. Confirm by checking that every entry in your exports map has a matching declaration on disk.
  • Broken declarations still emit. A type error can produce invalid .d.ts; run tsc --noEmit in 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 fail vite build. The fix is a separate tsc --noEmit gate 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 rollupTypes or re-declare it. The symptom is a declaration containing import('../../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 via rollupTypes, move the dependency to dependencies so consumers install it too, or re-declare a minimal local copy of the type you actually expose. Confirm by grepping the emitted index.d.ts for node_modules and for any specifier not in your published dependencies.

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.