A consumer using your library holds ⌘ and clicks a function name, expecting to read what it does. Without a declaration map, they land here:

// node_modules/@scope/my-library/dist/client.d.ts
export declare function createClient(options: ClientOptions): Client;

A signature, no body, and no way to see the behaviour without opening the built JavaScript. One compiler option and one files entry turn that same click into the original TypeScript, comments included.


Root Cause

declaration: true emits a .d.ts describing the public types, and that file is where the language server’s knowledge of your package ends. declarationMap: true additionally emits a .d.ts.map — a mapping from every position in the declaration back to the position in the source that produced it — and appends a //# sourceMappingURL= comment to the declaration.

The language server follows that map when two conditions hold: the map file is present next to the declaration, and the source file it names exists at the recorded relative path. Both live inside the published tarball, so both are controlled by the files array. Shipping one without the other is the common half-configuration, and it produces navigation that appears to work and opens an empty buffer.


Minimal Reproduction

{
  "compilerOptions": {
    "declaration": true,
    "outDir": "./dist",
    "rootDir": "./src"
  }
}
{
  "name": "@scope/my-library",
  "files": ["dist"],
  "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "default": "./dist/index.mjs" } }
}
ls dist/
index.d.ts   index.mjs

No .d.ts.map, no src/ in the tarball, and every consumer’s navigation stops at the declaration.


What the Language Server Follows

The navigation chain A consumer's import resolves through the types condition to a declaration file. With a declaration map present and its source shipped, navigation continues to the original TypeScript file; without them it stops at the declaration. Two more hops, both of which must be shipped consumer import ⌘-click here types condition dist/index.d.ts declaration map dist/index.d.ts.map source src/index.ts both of these are inside the tarball the files array decides whether they exist for a consumer, whatever your build emitted

Step-by-Step Fix

1. Turn on declaration maps

   "compilerOptions": {
     "declaration": true,
+    "declarationMap": true,
     "outDir": "./dist",
     "rootDir": "./src"
   }
npx tsc -p tsconfig.build.json --emitDeclarationOnly && ls dist/
index.d.ts   index.d.ts.map   index.mjs

2. Ship the sources the map references

   {
-    "files": ["dist"]
+    "files": ["dist", "src"]
   }

The relative path recorded in the map is computed from rootDir and outDir, so with dist/ and src/ as siblings the recorded source is ../src/index.ts — which only resolves if src/ is in the tarball.

3. Verify the recorded paths resolve after packing

d=$(mktemp -d); npm pack --silent | xargs -I{} mv {} "$d/"
cd "$d" && npm init -y >/dev/null && npm install ./*.tgz --silent

node -e '
  const fs = require("fs"), path = require("path");
  const root = "node_modules/@scope/my-library/dist";
  let bad = 0;
  for (const f of fs.readdirSync(root).filter(n => n.endsWith(".d.ts.map"))) {
    const map = JSON.parse(fs.readFileSync(path.join(root, f), "utf8"));
    for (const s of map.sources) {
      const abs = path.resolve(root, s);
      if (!fs.existsSync(abs)) { console.log("MISSING", f, "->", s); bad++; }
    }
  }
  console.log(bad ? `${bad} dangling reference(s)` : "every declaration map resolves");
'

Expected output: every declaration map resolves.

HAZARD PREVENTION

Symptom: Declaration maps and src/ both ship, but navigation still lands on the .d.ts.

Root cause: The published declaration files were produced by a declaration bundler that flattened many files into one. A flattened declaration has no one-to-one source correspondence, so either no map was emitted or the map points at an intermediate file that does not exist.

Fix: Choose between the two benefits deliberately — flattened declarations load faster in a consumer’s editor, per-file declarations support navigation. If you keep flattening, remove the declarationMap option so the output does not carry a broken reference.

4. Keep stripInternal in mind

Declaration maps expose the position of your source, and stripInternal removes members marked @internal from the declaration output. The two interact usefully: internal members disappear from the public types, and navigation from the remaining public members still reaches the real implementation file, which may contain those internal members. If that is not acceptable, the source directory should not be shipped at all — which means choosing the embedded-sources or no-sources configuration instead.


Verification

# the annotation exists in the declaration
tail -1 dist/index.d.ts

# and points at a file that is in the tarball
npm pack --dry-run | grep -E "\.d\.ts\.map|src/index\.ts"

Expected: a //# sourceMappingURL=index.d.ts.map line, and both the map and the source appearing in the pack listing.


Edge Cases / Gotchas

  • rootDir must include every source file. If a file outside rootDir is included, the emitted structure gains an extra directory level and every recorded map path shifts, breaking resolution.
  • Monorepo project references change the paths. With composite builds, declarations for a sibling package point into that package’s own src/, which must itself be published for cross-package navigation to work.
  • Editors cache resolution aggressively. After fixing the configuration, a consumer usually needs to restart the TypeScript server before navigation changes behaviour.
  • .npmignore can silently remove src/. When both files and .npmignore exist, the interaction surprises people; prefer files alone and verify with a pack listing.
  • Publishing src/ publishes comments. Internal notes, commented-out experiments and issue links in your source become readable. That is usually fine, and worth a skim before the first release that includes them.

Frequently Asked Questions

Why does go-to-definition stop at the d.ts file?

Because no declaration map exists, or the map exists and its referenced sources were not published. The language server follows a map when the map file is present and the target file is on disk; with either half missing it stops at the declaration.

Does shipping declaration maps require shipping source?

Yes, unless sources are embedded. A map is a set of position mappings and file references rather than a copy of the code, so the referenced .ts files must be in the tarball for navigation to reach them.

How much do declaration maps add to package size?

The maps are small — roughly a fifth of the declaration they describe. The real size question is the src/ directory, usually comparable to the emitted JavaScript, and it is never downloaded by end users at runtime.

Do bundled declaration files support maps?

Generally not usefully: a flattened declaration has no one-to-one correspondence with any source file. Faster editor loading and source navigation are a genuine trade-off here.

Can I ship declaration maps without shipping runtime source maps?

Yes, and it is a reasonable middle position. Declaration maps serve editor navigation at author time and are never loaded at runtime.



↑ Back to Source Maps and Debugging Published Packages