Shipping Declaration Maps for Go-to-Definition
Make go-to-definition land in real TypeScript source for consumers of your package by emitting declarationMap, shipping src, and verifying the map paths resolve.
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
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
declarationMapoption 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
rootDirmust include every source file. If a file outsiderootDiris 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.
.npmignorecan silently removesrc/. When bothfilesand.npmignoreexist, the interaction surprises people; preferfilesalone 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.
Related
- Source Maps and Debugging Published Packages — the runtime half of the same configuration.
- Declaration File Generation and Type Stripping — producing the declarations these maps describe.
- Publishing Source Maps Without Leaking Source — what to do when
src/cannot ship.