Source Maps and Debugging Published Packages
Ship source maps and declaration maps that work for consumers: sourcesContent trade-offs, go-to-definition, local linking, and readable stack traces from published code.
When something goes wrong inside a dependency, the consumer’s experience is decided by choices the library author made months earlier. With maps shipped correctly, a stack trace names src/client.ts:42 and the editor jumps to the original TypeScript. Without them, the same failure reports a column offset in a minified bundle and go-to-definition lands in a declaration file with no implementation to read. This guide covers the two map types, the privacy and size trade-offs of shipping sources, and the verification steps that prove the whole chain works from a consumer’s position rather than from your repository. It assumes a TypeScript build emitting to dist/ and a Node.js 20 baseline.
Prerequisites
Canonical Configuration Block
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"inlineSources": false,
"sourceRoot": "",
"outDir": "./dist",
"rootDir": "./src"
}
}
{
"name": "@scope/my-library",
"files": ["dist", "src"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.mjs"
}
}
}
Four settings carry the whole design. declarationMap is what makes editor navigation land in src/client.ts instead of dist/client.d.ts. sourceMap is what makes a runtime stack trace name a TypeScript line. inlineSources: false keeps the source text out of the map file, which means the src entry in files is doing the work of supplying it — a deliberate pairing, since maps that reference sources nobody ships are worse than no maps at all. And sourceRoot: "" keeps the recorded paths relative, so they resolve wherever the package is installed.
Step-by-Step Implementation
Step 1 — Choose how the source text reaches the consumer
There are three defensible configurations, and the choice is about privacy and package size rather than correctness.
The middle column deserves one caution: inlineSources: true embeds every source file the compiler touched, including files you might not intend to publish. Reviewing the map contents once after enabling it is worth the minute it takes.
Step 2 — Ship the maps and everything they reference
{
- "files": ["dist"]
+ "files": ["dist", "src"]
}
npm pack --dry-run | grep -E "\.map|src/" | head
npm notice 6.1kB dist/index.d.ts
npm notice 1.2kB dist/index.d.ts.map
npm notice 18.9kB dist/index.mjs
npm notice 12.4kB dist/index.mjs.map
npm notice 8.8kB src/index.ts
A map present without its sources produces the most confusing outcome available: the debugger reports the right file name and line, then shows an empty buffer or “source not available”, which reads to a consumer as a broken package rather than a deliberate choice.
Step 3 — Confirm a consumer stack trace resolves
d=$(mktemp -d); npm pack --silent | xargs -I{} mv {} "$d/"
cd "$d" && npm init -y >/dev/null && npm install ./*.tgz --silent
node --input-type=module -e "
const { createClient } = await import('@scope/my-library');
createClient({ baseUrl: 'not-a-url' }); // throws internally
" 2>&1 | head -6
file:///tmp/.../node_modules/@scope/my-library/src/client.ts:42
throw new TypeError(`Invalid baseUrl: ${options.baseUrl}`);
^
TypeError: Invalid baseUrl: not-a-url
at createClient (src/client.ts:42:13)
The trace naming src/client.ts:42 rather than dist/client.mjs:1:2847 is the whole payoff. Node.js 20 resolves maps for uncaught errors by default; older runtimes need --enable-source-maps.
HAZARD PREVENTION
Symptom: Stack traces from the published package show
dist/index.mjs:1:2847even though.mapfiles are in the tarball.Root cause: The
//# sourceMappingURL=comment was stripped by a minifier, or the build wrote maps to a different directory than the JavaScript that references them.Fix: Check the last line of the emitted file for the comment, and confirm the referenced path resolves relative to that file. Minifier configurations frequently need an explicit option to preserve the annotation.
Step 4 — Confirm go-to-definition reaches the source
Declaration maps are the half nobody tests, because the author’s editor resolves through their own source tree and always behaves correctly.
# from the sandbox: does the declaration map point at a file that exists?
node -e '
const fs = require("fs");
const p = "node_modules/@scope/my-library/dist/index.d.ts.map";
const map = JSON.parse(fs.readFileSync(p, "utf8"));
const base = require("path").dirname(p);
for (const s of map.sources) {
const abs = require("path").resolve(base, s);
console.log(fs.existsSync(abs) ? "ok " : "MISS", s);
}
'
ok ../src/index.ts
ok ../src/client.ts
A MISS line means a consumer’s editor will fall back to the declaration file — navigation still “works”, but lands on a type signature with no implementation behind it.
Step 5 — Keep the maps honest through the bundler
A bundled build must be told to produce maps, and to keep the chain intact when it processes already-mapped input:
// tsup.config.ts
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
sourcemap: true, // emit .map files next to the output
clean: true,
});
Tooling Validation
# 1. every emitted file has a map, and every map has its sources
node -e '
const fs = require("fs"), path = require("path");
let bad = 0;
for (const f of fs.readdirSync("dist")) {
if (!/\.(mjs|cjs|js|d\.ts)$/.test(f)) continue;
const map = path.join("dist", f + ".map");
if (!fs.existsSync(map)) { console.log("no map:", f); bad++; continue; }
const m = JSON.parse(fs.readFileSync(map, "utf8"));
for (const s of m.sources ?? []) {
if (!m.sourcesContent && !fs.existsSync(path.resolve("dist", s))) {
console.log("dangling source:", f, "->", s); bad++;
}
}
}
console.log(bad ? `${bad} problem(s)` : "all maps resolve");
'
# 2. the manifest ships what the maps reference
npx publint --strict
# 3. sourceMappingURL comments survived the build
tail -1 dist/index.mjs | grep -q sourceMappingURL && echo "annotation present"
Sample passing output:
all maps resolve
annotation present
Compatibility Matrix
| Capability | Node 18 | Node 20 LTS | Node 22 | Chrome DevTools | VS Code / TS Server |
|---|---|---|---|---|---|
| Source maps for uncaught errors | --enable-source-maps |
Default on | Default on | N/A | N/A |
Source maps in Error.stack |
Flag | Default on | Default on | N/A | N/A |
| Declaration maps (go-to-definition) | N/A | N/A | N/A | N/A | TypeScript 4.0+ |
sourcesContent embedded sources |
Supported | Supported | Supported | Supported | Supported |
External .map beside the file |
Supported | Supported | Supported | Supported | Supported |
Maps inside node_modules by default |
Yes | Yes | Yes | Requires “ignore list” opt-out | Yes |
What a Consumer Actually Experiences
The value of all this configuration is easiest to judge by looking at the four situations in which a consumer meets your package’s internals, and what each one shows them depending on what you shipped.
An exception thrown inside your code. With source maps and sources shipped, the trace names your TypeScript file, line and column, and Node.js prints the offending line. With maps but no sources, the trace names the right location and shows no code. With neither, the trace names a position in a bundled file that means nothing to anybody, and the consumer’s bug report contains that position instead of useful information.
A breakpoint set in a debugger. Stepping into a dependency is the fastest way to understand a misbehaviour, and it only works when the debugger can find source text. This is the case where inlineSources earns its size: a self-contained map needs no src/ directory in the tarball and no path resolution to succeed.
Go-to-definition in an editor. Without a declaration map, navigation stops at dist/index.d.ts — a signature with no body. With one, it opens the implementation, and the consumer can read what the function actually does instead of guessing from the type. For a library whose value is in its behaviour rather than its API shape, this single setting changes how approachable the package feels.
Reading the package on disk. Some consumers simply open node_modules/@scope/my-library/ and look. Shipping src/ makes that productive; shipping only minified dist/ makes it futile. This is not a technical consideration so much as a documentation one, and it is worth weighing alongside the tarball size it costs.
Against those four benefits sits one real cost and one imagined one. The real cost is size: maps are typically 60–80% of the size of the code they describe, and src/ adds its own weight, so a package can double its unpacked size by shipping everything. That matters for install footprint and for anyone bundling your package for the browser — though maps are never downloaded by a browser unless devtools are open, so the runtime cost for end users is zero.
The imagined cost is security. Publishing source is not a meaningful disclosure for the overwhelming majority of npm packages: the published JavaScript is already the whole implementation, minified or not, and anyone motivated enough to read it will. Where source genuinely cannot be published — a licensed component, a package containing an embedded algorithm under contract — the right configuration is maps without sources, covered in its own guide, rather than no maps at all.
Keeping the Chain Intact Through the Build
Every step between your TypeScript and the published file can break the map chain, and the failure is always silent. Four checkpoints cover the common pipelines.
Compilation. tsc with sourceMap: true writes a .map beside each output and appends a //# sourceMappingURL= comment. If you compile with a syntax-only transformer instead — esbuild, SWC — the equivalent option must be enabled explicitly, and the resulting map describes the transform rather than the type-stripping, which is the same thing in practice for TypeScript input.
Bundling. A bundler that consumes already-mapped input must compose the maps: its output map should point past its own transformation all the way back to your TypeScript. Every major bundler does this correctly when told to emit maps; the failure mode is forgetting to tell it, in which case the map describes the bundle’s own input files and stops there.
Minification. This is the most common break. Minifiers frequently strip comments, including the sourceMappingURL annotation, and some require an explicit option to consume the incoming map rather than discarding it. Whenever a trace suddenly stops resolving after a build-tool change, check the last line of the emitted file first.
Publishing. The files array decides whether the maps make it into the tarball at all. A build that produces perfect maps into dist/ and a manifest whose files lists only dist/**/*.mjs publishes code with dangling map references.
# one command that catches three of the four
node -e '
const fs = require("fs");
const js = fs.readFileSync("dist/index.mjs", "utf8");
const m = js.match(/\/\/# sourceMappingURL=(\S+)/);
if (!m) { console.log("FAIL: no sourceMappingURL annotation"); process.exit(1); }
const mapPath = require("path").resolve("dist", m[1]);
if (!fs.existsSync(mapPath)) { console.log("FAIL: annotation points at a missing file"); process.exit(1); }
const map = JSON.parse(fs.readFileSync(mapPath, "utf8"));
const kind = map.sourcesContent ? "embedded sources" : "external sources";
console.log(`OK: map found (${map.sources.length} source(s), ${kind})`);
'
OK: map found (7 source(s), external sources)
Running that after the build, and again against the packed tarball rather than the working directory, is the difference between believing the chain works and knowing it does. The tarball check matters because it is the only one that exercises the files array — the step where correct maps most often stop being shipped.
Debugging Across the Package Boundary
Even with perfect maps, debugging a problem that spans a consumer’s application and your library has its own mechanics, and a few defaults work against you.
Editors and runtimes skip node_modules by default. Chrome DevTools has an ignore list that hides dependency frames from stack traces; VS Code’s JavaScript debugger uses skipFiles with a node_modules entry. Both are sensible defaults for application developers and unhelpful when the bug is in a dependency. Documenting the one-line opt-out in your contributing guide saves every future bug reporter the same discovery:
{
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/app.js",
"skipFiles": ["<node_internals>/**"],
"resolveSourceMapLocations": [
"${workspaceFolder}/**",
"!**/node_modules/!(@scope)/**"
]
}
That configuration keeps other dependencies quiet while resolving maps for your scope specifically — the middle ground between drowning in framework frames and seeing none of your own.
Asynchronous boundaries lose frames. A promise rejection crossing from your library into consumer code produces a trace that starts at the rejection site and says nothing about the call path that got there. Error.captureStackTrace at the entry point, or simply constructing the error object early and rethrowing it, preserves the context that makes a report actionable. For a library, the practical rule is to create errors where the cause is known rather than where the failure surfaces.
Errors thrown from callbacks belong to the caller. When a consumer passes a function into your library and it throws, the trace names their file — which is correct, and which also means your library’s frames are what give it context. Keeping intermediate frames rather than flattening them with a try/catch-and-rethrow makes those traces more useful, not less.
A final practice worth adopting: attach machine-readable context to thrown errors rather than only a message. A code property, the offending input, and the option name that was wrong turn a support thread into a search, and cost nothing at runtime.
export class ConfigError extends Error {
readonly code = "ERR_MY_LIBRARY_CONFIG";
constructor(readonly option: string, readonly received: unknown) {
super(`Invalid value for "${option}": ${JSON.stringify(received)}`);
this.name = "ConfigError";
}
}
A consumer who pastes that message into an issue has given you the option name, the value, and a searchable code — and if the maps are shipped, the trace beneath it names the exact line of your source that raised it.
A Release Checklist for Debuggability
Everything above reduces to four checks, each of which fails loudly rather than silently once it is in the build.
A package that passes all four gives a consumer the same debugging experience they have in their own code, which is a larger difference to how a library feels than any amount of API documentation.
Guides in This Section
- Shipping Declaration Maps for Go-to-Definition — making editor navigation land in real source for installed packages.
- Publishing Source Maps Without Leaking Source — the closed-source configuration, and what it costs in debuggability.
- Debugging a Published Package with npm link — reproducing a consumer’s failure locally without publishing a version.
- Fixing Broken Stack Traces in Bundled ESM Output — when the annotation, the paths, or the map chain break during bundling.
Related
- Declaration File Generation and Type Stripping — the declaration output these maps accompany.
- Optimizing tsconfig.json for Library Distribution — where
sourceMapanddeclarationMapsit among the options that ship. - The files Field and Controlling npm pack — deciding whether
src/travels with the package. - Modern Build Tools: tsup, Rollup, and esbuild — configuring map emission in each build tool.