Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

Publishing Declaration Maps for Go-to-Definition

When a consumer presses Go to Definition on a function from your package, their editor opens a .d.ts file full of signatures and no implementation. Declaration maps — .d.ts.map files — let the editor jump to your original TypeScript source instead, which makes a library dramatically easier to understand and debug. Getting them to work requires three things to line up: the compiler option, the source files in the tarball, and paths that still resolve after installation. This guide sets that up, explains the trade-offs, and shows how the same idea powers instant navigation inside a monorepo.

What declaration maps do

A declaration map is a source map for types. For each emitted dist/client.d.ts, TypeScript writes dist/client.d.ts.map, which records where each declaration came from in src/client.ts. Editors built on the TypeScript language service follow that map when a user navigates to a symbol: if the source file exists at the mapped path, they open it at the right line.

Without maps, consumers see this when they navigate:

// node_modules/your-lib/dist/client.d.ts
export declare class Client {
    constructor(options: ClientOptions);
    request<T>(path: string, init?: RequestInit): Promise<T>;
}

With maps and shipped sources, they land in the implementation, with comments, private helpers and the actual logic.

How Go to Definition follows a declaration map The editor resolves a symbol to a declaration file, reads its .d.ts.map, maps the position to a source file, and opens the source if it exists in the package. Go to Definition on client.request() dist/client.d.ts declaration the compiler resolved client.d.ts.map maps position to ../src/client.ts src/client.ts opens if the file shipped
The map only helps if the source file it points to is present in node_modules.

Enabling declaration maps

Turn on the option alongside declaration output:

{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "rootDir": "src",
    "outDir": "dist"
  }
}

Then include the sources in the published package so the mapped paths resolve:

{
  "name": "your-lib",
  "files": ["dist", "src", "!src/**/*.test.ts", "!src/**/__fixtures__"]
}

The map stores a relative path such as ../src/client.ts. Inside the installed package that resolves to node_modules/your-lib/src/client.ts, so the directory layout between dist and src must be preserved exactly — which rootDir guarantees.

Package layout that keeps declaration maps working The published package contains dist with .d.ts and .d.ts.map files and src with the TypeScript sources the maps point to. node_modules/your-lib/ package.json dist/ client.js runtime client.d.ts types client.d.ts.map sources: ../src/client.ts src/ client.ts target of Go to Definition
Relative paths in the maps resolve only if src ships next to dist with the same layout as in the repository.

Trade-offs: size, rollups and bundlers

Shipping sources increases the tarball size, typically by the size of your source tree. For most libraries that is a few hundred kilobytes and worth it; for very large packages, weigh the cost. Two build setups need extra thought:

  • Rolled-up declarations. API Extractor and bundler declaration plugins merge many declaration files into one. Most rollup tools do not produce declaration maps, so navigation lands in the rollup file. If source navigation matters more to you than a single declaration file, ship per-file declarations with maps; the trade-off is discussed in Bundling Declarations with API Extractor.
  • Bundled JavaScript. Runtime source maps (.js.map) are separate from declaration maps. A bundler can emit .js.map that points into src/, which helps debugger stepping; declaration maps come from the type emit step. You can have one without the other.
Declaration output strategies and navigation Compares per-file declarations with maps, per-file without maps, and rolled-up declarations on where Go to Definition lands, tarball size and API surface control. per-file + maps + src per-file, no maps rolled-up .d.ts Go to Definition lands in original .ts source per-file .d.ts one large .d.ts Tarball size adds sources small smallest Internal types exposed all modules visible all modules visible trimmed Setup effort two options none extra tool
Per-file declarations with maps give the best navigation; rollups give the tightest API surface.

Declaration maps inside a monorepo

Declaration maps matter even more between workspace packages. With TypeScript project references, a package consuming @acme/ui compiles against @acme/ui's emitted declarations. Without maps, Go to Definition from apps/web into a @acme/ui component lands in packages/ui/dist/Button.d.ts, and editing there is pointless. With declarationMap: true in the shared library preset, navigation lands in packages/ui/src/Button.tsx, where changes actually belong — and the editor's rename and find-references features work across packages.

Because the sources are already next to the output in a workspace, no files change is needed for this internal benefit. Enable the option once in the shared base, as shown in Sharing a Base tsconfig Across Workspaces, and every package gains it.

Build tools and declaration maps

Whether you get declaration maps depends on which tool emits your declarations, and not every tool supports them.

tsc supports them fully: declarationMap: true writes one .d.ts.map per declaration, with relative sources that respect rootDir. This is the most reliable way to get working maps, and it is why many libraries keep tsc for the declaration step even when a bundler produces the JavaScript.

tsup emits declarations through its own dts pipeline, which bundles them into one file per entry; its bundled output does not carry declaration maps in the way tsc does. Libraries that want maps and use tsup commonly set dts: false and run tsc --emitDeclarationOnly --declarationMap as a separate step.

Vite library mode with vite-plugin-dts can emit per-file declarations with maps when bundled types (rollupTypes) are turned off; with rollupTypes on, maps are lost for the same reason as other rollups.

Project references builds (tsc -b) emit maps for every referenced project when the option is in the shared preset, which is what makes cross-package navigation work in monorepos.

A practical recipe for a published library that wants both a bundled runtime and good navigation is therefore: bundle JavaScript with your preferred tool, emit declarations and declaration maps with tsc --emitDeclarationOnly, ship src alongside, and validate the result with Are the Types Wrong. You give up a single rolled-up declaration file, and in return consumers read your real code.

What consumers need to do

Nothing, in the common case. VS Code, WebStorm and other editors built on the TypeScript language service follow declaration maps automatically. One setting can interfere: if a consumer's tsconfig.json sets "disableSourceOfProjectReferenceRedirect" or they use an editor extension that forces .d.ts navigation, maps are bypassed. The other common interference is skipLibCheck, which does not affect navigation but does mean consumers never see type errors in your shipped sources — which is exactly what you want.

Worked example: a design system team's support load

A design system team fields a steady stream of questions from product engineers who want to know how a component handles a prop — questions the source would answer immediately. Consumers navigating into the package land in dist/Button.d.ts, which shows the props interface but none of the defaults or logic. The team enables declarationMap and sourceMap, adds src to files (excluding tests and stories), and publishes a minor release. The tarball grows by 280 KB unpacked. Within a few weeks, the "how does this prop work" questions drop noticeably, because engineers can read the implementation from their own editor. The team also finds that bug reports now often include the exact source line, which shortens triage.

Common problems

Navigation still opens the .d.ts. The source path in the map does not exist. Open the .d.ts.map and check its sources array against the installed package; a missing src directory or a different rootDir between builds is the usual cause.

Maps point to absolute paths from the build machine. Some tools write absolute sources entries. Configure the tool to emit relative paths, or set sourceRoot deliberately.

Consumers see sources but types feel different. If the map points to sources that were changed after the build (for example, a later commit's src packed with an earlier dist), navigation lands on the wrong lines. Always build and pack in the same job from the same commit, typically in prepack.

CLI validation and debug commands

# Every .d.ts in the tarball has a .d.ts.map, and src is present
npm pack --dry-run 2>&1 | grep -E "\.d\.ts\.map$" | head
npm pack --dry-run 2>&1 | grep -E " src/" | head

# Inspect where a map points
node -p "JSON.parse(require('fs').readFileSync('dist/client.d.ts.map','utf8')).sources"

# Verify the mapped source exists relative to the map
node -e "const p=require('path'),f=require('fs');const m=JSON.parse(f.readFileSync('dist/client.d.ts.map','utf8'));for(const s of m.sources){const t=p.resolve('dist',s);console.log(f.existsSync(t)?'ok':'MISSING',t)}"

Prevention and CI/CD guardrails

  • Enable declarationMap in the shared library preset so every package emits maps consistently.
  • Check map targets in CI with the script above, run against the packed tarball's contents.
  • Exclude tests, stories and fixtures from shipped sources with negated globs in files.
  • Build and pack from one commit in one job, so maps and sources always match.

Frequently Asked Questions

Do declaration maps affect runtime performance? No. They are read only by editors and tools. Node.js and bundlers ignore .d.ts.map files.

Is shipping my TypeScript source a licensing concern? Your compiled JavaScript is already public in the tarball; the source adds readability, not new rights. If your licence or policy prohibits distributing source, ship declarations without maps.

Why does my editor show the source but with red squiggles? The consumer's editor type-checks your source under their compiler settings when they open it. Errors there do not affect their build, which compiles against your declarations; they are a side effect of viewing the file.

Should I also ship JavaScript source maps? Yes, if you want debugger stepping and readable stack traces to point at your TypeScript. sourceMap: true writes .js.map files that reference the same src directory, so shipping sources for declaration maps gives you runtime maps almost for free.

Can I ship maps for some entry points and not others? You can, by emitting declarations for different entry points with different settings, but mixed behaviour confuses users. Decide once for the whole package.

Related

TypeScript Declaration Publishing