Mapping Minified Variable Names Back to Source

A technical explanation of how the source map V3 names array encodes original identifiers, why most minified variable names are never restored by DevTools even when a map is present, and practical techniques for recovering variable identity through scope analysis and name mapping when the map is absent or incomplete. This page is part of Debugging Minified Code Without Source Maps and sits inside the Source Map Generation & Stack Trace Debugging reference.

Source Map V3 Names Array — Variable Name Resolution Pipeline A diagram showing two parallel paths: left path shows a minified bundle with single-letter variables t, e, n flowing into a VLQ mapping segment that references the names array, which resolves back to the original source names fetchUser, response, userId. Right path shows the failure case where the names index is absent from the mapping segment, leaving the variable unresolved in DevTools. With names index in mapping Without names index Minified bundle function t(e){var n=e.id;…} VLQ mapping segment gen col | src file | src line | src col | names idx A | A | A | A | C ← names[1] names array lookup ["fetchUser","response","userId",…] DevTools shows: response (original name restored) Minified bundle function t(e){var n=e.id;…} VLQ mapping segment gen col | src file | src line | src col (4 fields only) A | A | A | A ← no names field No names lookup possible Scope panel: n = {id: 42, name: "Alice"} Only runtime value visible — not original name

Symptom / Trigger

You have a source map (or you have generated one retroactively), you load it in DevTools, and symbolication partially works — source file names and line numbers resolve correctly — but variable names in the Scope panel still show as t, e, n, r, or other single-character identifiers rather than the original names. The stack trace itself resolves to a source line, yet you cannot tell which variable in that line corresponds to the broken value.

// Stack trace after symbolication — file and line resolved, but variables still opaque
TypeError: Cannot read properties of undefined (reading 'length')
    at processItems (src/utils/list.js:47:18)   ← line resolved ✓

// Scope panel at that breakpoint shows:
Local:
  t  →  undefined          ← should be "items" but name isn't restored
  e  →  Array(0)
  n  →  42

Root Cause Explanation

The source map V3 format encodes location mappings as VLQ-compressed segments in the mappings field. Each segment can contain up to five values: generated column, source file index, original line, original column, and — optionally — a names array index. That fifth value points into the top-level names array, which holds the original identifier strings.

{
  "version": 3,
  "sources": ["src/utils/list.js"],
  "names": ["processItems", "items", "userId", "result"],
  "mappings": "AAAA,SAASA,aAAaC,EAAOC"
}

In this fragment, the VLQ segment SAASA contains all five fields — the fifth decodes to index 0, meaning names[0] = "processItems". But the segment aAAaC only contains four fields — no names index — so the variable at that position cannot be restored, even though the source line is known.

The problem arises from how minifiers emit the names field. Terser and esbuild only add a names entry for function declarations and named function expressions when sourceMap.includeSources or an equivalent flag is enabled. They do not emit names entries for:

  • Parameters renamed by mangle.properties or the default identifier mangler.
  • Arrow function parameters.
  • Destructured bindings.
  • Variables introduced by const/let/var inside blocks.

The result is that most local variable renames never make it into the names array, so the scope panel cannot restore them even with a complete source map.

// Original source — three identifiers the minifier will rename
function processItems(items) {
  const userId = getCurrentUser().id;  // 'items' and 'userId' will be mangled
  return items.filter(item => item.userId === userId);
}

// Minified output (Terser default settings):
// function t(e){const n=r().id;return e.filter(t=>t.n===n)}
// 'e', 'n', 't' have no names entries in the source map

Step-by-Step Fix

Step 1 — Inspect the names Array in the Raw Source Map

Download the .map file and examine its names array and the density of five-field segments in mappings:

# Download and pretty-print the source map
curl -s https://cdn.example.com/assets/main.a8f3c.js.map | \
  node -e "
    const sm = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
    console.log('names count:', sm.names.length);
    console.log('first 20 names:', sm.names.slice(0,20));
    // Count segments with 5 fields vs 4 fields
    const segs = sm.mappings.split(/[;,]/);
    console.log('total segments:', segs.length);
  "

A names count of zero or close to zero confirms that the minifier produced no name mappings. A low count relative to total segments means most variables are unresolved.

Step 2 — Rebuild with Name Preservation Enabled

For the purposes of local debugging, regenerate the source map with name entries populated. The configuration differs by minifier:

// Terser — enable names entries in the output source map
import terser from '@rollup/plugin-terser';

export default {
  plugins: [
    terser({
      sourceMap: {
        includeSources: true,   // embed original source content
      },
      mangle: {
        keep_fnames: true,      // preserve function declaration names
        keep_classnames: true,  // preserve class names
        // Note: local variable names (let/const/var) are still mangled
        // even with keep_fnames — this is a Terser limitation
      },
      compress: {
        passes: 1,              // fewer passes = more predictable name mapping
      },
    }),
  ],
};
# esbuild — keepNames flag preserves names in both code and source map
npx esbuild src/index.js \
  --bundle \
  --minify \
  --keep-names \          # preserves function/class names in the source map names array
  --sourcemap \
  --outfile=dist/main.js

After rebuilding, re-inspect the names array count. With keep_fnames or --keep-names, function and class identifiers appear; local const/let variables remain mangled. This is a fundamental limitation — the V3 specification supports names entries but minifier implementations focus on the identifiers that appear in stack traces (function names), not every local binding.

Step 3 — Use Runtime Scope Analysis to Recover Variable Identity

When the names array cannot help, match runtime variable values to known application data shapes. This is the most reliable technique when maps are absent or incomplete.

// DevTools Console — enumerate all variables in scope at a breakpoint
// Paste while paused at the failure point
(function inspectScope() {
  // This runs in the paused frame's scope via the Console
  // Replace t, e, n with the actual single-letter names visible in the Scope panel
  const snapshot = {
    t: typeof t !== 'undefined' ? t : '(not in scope)',
    e: typeof e !== 'undefined' ? e : '(not in scope)',
    n: typeof n !== 'undefined' ? n : '(not in scope)',
  };
  console.table(snapshot);
  // Cross-reference each value with known API response shapes or Redux state slices
})();

Compare each runtime value against your application’s TypeScript interfaces or API contract. A value of {id: 42, name: "Alice", email: "…"} maps unambiguously to the User type. A value of [] (empty array) where content was expected confirms the undefined-length error.

Step 4 — Use the mozilla/source-map Library for Programmatic Name Lookup

If you have a source map and need to look up names at specific positions in bulk — for example, to annotate a large stack trace — use the source-map library directly:

// Node.js script — look up original name at a specific generated position
import { SourceMapConsumer } from 'source-map';
import fs from 'fs';

const rawMap = JSON.parse(fs.readFileSync('./dist/main.js.map', 'utf8'));

await SourceMapConsumer.with(rawMap, null, (consumer) => {
  const result = consumer.originalPositionFor({
    line: 1,        // minified line (always 1 for single-line bundles)
    column: 84732,  // column from Error.stack
  });

  console.log('Source file:', result.source);    // e.g. "src/utils/list.js"
  console.log('Source line:', result.line);       // e.g. 47
  console.log('Source col: ', result.column);     // e.g. 18
  console.log('Original name:', result.name);     // e.g. "processItems" or null
});
// result.name is null when the segment has no fifth field (no names entry)

result.name being null at a given position is the programmatic confirmation that the minifier did not emit a names entry for that token. The source line and column still resolve correctly — you just cannot get the identifier name from the map alone.

For more on using this library in operational scripts, see Local Symbolication with Mozilla source-map Library.

Verification

Confirm that your rebuilt source map actually contains names entries before relying on it for debugging:

// Node.js verification script — count resolvable names in a source map
import { SourceMapConsumer } from 'source-map';
import fs from 'fs';

const raw = JSON.parse(fs.readFileSync('./dist/main.js.map', 'utf8'));

let withName = 0;
let withoutName = 0;

await SourceMapConsumer.with(raw, null, (consumer) => {
  consumer.eachMapping((mapping) => {
    if (mapping.name) {
      withName++;
    } else {
      withoutName++;
    }
  });
});

console.log(`Segments with names: ${withName}`);      // e.g. 312
console.log(`Segments without names: ${withoutName}`); // e.g. 8847
console.log(`Name coverage: ${((withName / (withName + withoutName)) * 100).toFixed(1)}%`);
// Expect low coverage (5-15%) even with keep_fnames — most local vars are still mangled

A name coverage above 10% with keep_fnames enabled is typical. Zero coverage means the minifier or bundler plugin is not writing name mappings — double-check the sourceMap configuration object.

Edge Cases & Gotchas

  • keep_fnames does not preserve arrow function parameter names. Arrow function parameters are treated as ordinary variable bindings by Terser and are mangled regardless. Only traditional function declarations and named function expressions benefit from keep_fnames.
  • The names array is global across all sources. A single names array covers all modules in a concatenated bundle. Index values in VLQ segments reference this global array, so a mis-indexed names array (caused by incorrect incremental map merging) produces wrong name lookups that are harder to detect than missing lookups.
  • Property access names are never restored. If the original code says user.firstName, the minifier may rename the property to user.a with mangle.properties enabled. This renaming is not tracked in the source map names array at all — property mangling produces a completely separate name table inside the minifier that is not exposed in the V3 format.
  • sourcesContent is separate from names. The sourcesContent field embeds the raw original source text. DevTools uses it to display the original file. But it does not help with name resolution — the names array and VLQ fifth field are the only mechanism for restoring identifier names in the Scope panel.

FAQ

Why does DevTools show the correct source line but still display single-letter variable names in the Scope panel?

Because line and column resolution uses the first four VLQ fields (always present), while name resolution requires the optional fifth field. Most minifiers only emit the fifth field for function and class declarations, not for local variable bindings. The Scope panel can only rename variables for which a names entry exists.

Can I force Terser to emit names entries for all variables, including local let/const bindings?

Not with standard Terser configuration. Terser’s source map names support is intentionally limited to function and class identifiers. To get names for local bindings, you would need to disable mangling entirely (mangle: false) — which significantly reduces compression ratios and is not practical for production builds.

Does the V3 specification require names entries for all renamed identifiers?

No. The specification says names entries are optional. Browsers and symbolication tools must handle their absence gracefully. The spec was designed with the assumption that names entries would be populated for “interesting” identifiers like exported function names, not every local variable — a pragmatic trade-off that tools like Sentry and Datadog reflect by relying on source-line symbolication rather than scope-level name restoration.