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.


Two map types, two audiences A source map links the emitted JavaScript back to the original TypeScript so runtime stack traces and debugger breakpoints resolve. A declaration map links the emitted declaration file back to the same source so editor go-to-definition reaches the implementation. Different files, different tools, same origin src/client.ts the original source dist/client.mjs + .mjs.map used by: the runtime and debugger gives: readable stack traces, breakpoints dist/client.d.ts + .d.ts.map used by: the editor's language server gives: go-to-definition into real code both are emitted from the same compile, and both need shipping

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.

Three ways to supply source to a consumer Shipping the src directory alongside maps keeps map files small and gives full navigation. Embedding sources with inlineSources makes each map self-contained but larger. Shipping maps with no sources gives line numbers only and is appropriate when source cannot be published. Pick one deliberately — the default is the worst of the three ship src/ + maps inlineSources: false files: ["dist", "src"] full navigation and traces tarball grows by src size the usual choice for open-source libraries embed with inlineSources inlineSources: true files: ["dist"] maps are self-contained map files roughly double good when the layout of src/ is likely to change maps without sources inlineSources: false files: ["dist"] line numbers resolve, text does not smallest option for closed source, paired with a symbol server

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:2847 even though .map files 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.

Four debuggability checks Check that the sourceMappingURL annotation survived the build, that map files are in the tarball, that referenced sources resolve or are embedded, and that declaration maps point at files that ship. Four checks, all of them one command each annotation survived tail -1 on each emitted file maps are in the tarball npm pack --dry-run, grep for .map sources reachable shipped in src/, or embedded in the map declaration maps resolve every .d.ts.map source exists on disk

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



Back to TypeScript Configuration & Build Tooling