Decoding VLQ Mappings by Hand
The mappings field in every source map v3 document is a compact string of Base64-encoded Variable Length Quantity integers that encode every coordinate mapping between generated and original source positions. Understanding how to decode it manually is essential for debugging custom symbolication pipelines, validating post-build transformations, and diagnosing off-by-one errors in frame resolution — all central concerns in source map generation and stack trace debugging.
Symptom / Trigger
A custom or third-party symbolication script produces wrong original line/column values, or throws on valid map strings:
RangeError: Invalid VLQ character at offset 14
at decodeVLQ (custom-symbolicate.js:23:11)
# Or wrong coordinates — off-by-one or wildly wrong line numbers:
originalPositionFor({line:1, column:50}) → { source: 'src/index.ts', line: -4, column: 18 }
# Negative line number is impossible — sign bit was misread
Consider a real mappings fragment: "AAAA,SAASA,EAAKC". A naive parser that treats every character as an independent unsigned integer, or that misidentifies the continuation bit mask, produces:
// Naive wrong decoder: treats each char as raw base64 index, no sign or continuation handling
'AAAA'.split('').map(c => 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.indexOf(c))
// → [0, 0, 0, 0] (looks right by accident — A is 0 in every scheme)
'SAASA'.split('').map(c => 'ABCDEF...'.indexOf(c))
// → [18, 0, 0, 18, 0] (five raw values — WRONG: this is not how VLQ groups work)
// Correct decode of 'SAASA' as a segment: [9, 0, 0, 9, 0]
// The naive approach misses the sign-bit shift that halves the magnitude
The segment SAASA should decode to the five per-field integers [9, 0, 0, 9, 0], representing: generated column delta +9, source index delta 0, original line delta 0, original column delta +9, names index delta 0.
Root Cause Explanation
Source map VLQ uses a variant of Base64 where each character encodes 6 bits, with specific bit roles:
- Bit 5 (mask
0x20, the MSB of the 6-bit group): The continuation flag. If1, the next character belongs to the same integer. If0, this character is the last in the group. - Bit 0 of the first character only: The sign bit.
0= positive,1= negative. This bit position in continuation characters is plain data. - Bits 4…1 of the first character, bits 4…0 of continuation characters: The magnitude, assembled LSB-first across groups.
The base64 alphabet maps A=0, B=1, ..., Z=25, a=26, ..., z=51, 0=52, ..., 9=61, +=62, /=63. This is the standard base64 alphabet, not URL-safe base64.
Each comma-separated segment in a mappings line has 1 to 5 VLQ groups:
| Field index | Meaning | Signed? | Notes |
|---|---|---|---|
| 0 | Generated column delta | No (always positive) | The one exception: first field has no sign bit |
| 1 | Source file index delta | Yes | Omitted if no source for this segment |
| 2 | Original line delta | Yes | Present if field 1 is present |
| 3 | Original column delta | Yes | Present if field 2 is present |
| 4 | Names index delta | Yes | Optional; only if segment maps to a named identifier |
All fields are deltas from the running state, not absolute values. Field 0 (generated column) resets to 0 at each semicolon (new generated line). Fields 1–4 carry forward across semicolons.
// Track running state across an entire mappings parse
const state = {
generatedLine: 0, // incremented by ';' count
generatedColumn: 0, // reset to 0 at each ';', then accumulated per segment
sourceIndex: 0, // accumulated across all segments and lines
originalLine: 0,
originalColumn: 0,
namesIndex: 0,
};
A single-field segment (field 0 only) means the generated position has no source mapping — it was synthesized by the minifier or bundler with no original source counterpart. This is common for module wrapper boilerplate.
Step-by-Step Fix
1. Build the base64 decode table
// CHARS maps base64 character → integer index (0–63)
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
// Pre-build a reverse lookup for O(1) character decode
const CHAR_TO_INT = new Uint8Array(128).fill(255); // 255 = invalid sentinel
for (let i = 0; i < CHARS.length; i++) {
CHAR_TO_INT[CHARS.charCodeAt(i)] = i; // populate only the 64 valid characters
}
function base64CharToInt(ch) {
const v = CHAR_TO_INT[ch.charCodeAt(0)];
if (v === 255) throw new RangeError(`Invalid VLQ character: ${JSON.stringify(ch)}`);
return v;
}
2. Write decodeVLQInteger
// Decodes one complete VLQ integer from an array of characters, consuming chars in-place
function decodeVLQInteger(chars) {
let result = 0;
let shift = 0;
let digit;
do {
if (chars.length === 0) throw new RangeError('Unexpected end of VLQ sequence');
digit = base64CharToInt(chars.shift()); // consume one character from the front
result |= (digit & 0x1F) << shift; // lower 5 bits contribute to the magnitude
shift += 5;
} while (digit & 0x20); // bit 5 is the continuation flag; loop while set
// Bit 0 of the assembled result is the sign bit (set during the first group's contribution)
return (result & 1) ? -(result >> 1) : (result >> 1); // odd=negative, even=positive
}
Worked trace for the character S (index 18, binary 010010):
digit = 18, binary010010digit & 0x1F = 18 & 31 = 18→result |= 18 << 0 = 18(binary10010)digit & 0x20 = 18 & 32 = 0→ continuation flag is 0, loop endsresult = 18(binary10010); bit 0 =0(positive); magnitude =18 >> 1 = 9- Return +9
3. Write decodeSegment
// Decodes all 1–5 VLQ integers from a segment character array, applying deltas to state
function decodeSegment(chars, state) {
if (chars.length === 0) return null; // empty segment is valid (trailing comma)
// Field 0: generated column — always present, UNSIGNED (sign bit is data bit in the spec)
// Implementation note: decodeVLQInteger treats bit 0 as sign universally; for field 0
// the spec says the value is always non-negative, so sign=0 is guaranteed by the encoder.
const generatedColumnDelta = decodeVLQInteger(chars);
state.generatedColumn += generatedColumnDelta; // accumulate, don't reset
if (chars.length === 0) {
// Single-field segment: no original source for this generated position
return { generatedColumn: state.generatedColumn };
}
// Fields 1–3 are always emitted together when a source mapping exists
state.sourceIndex += decodeVLQInteger(chars); // source file index delta
state.originalLine += decodeVLQInteger(chars); // original line delta
state.originalColumn += decodeVLQInteger(chars); // original column delta
const mapping = {
generatedColumn: state.generatedColumn,
sourceIndex: state.sourceIndex,
originalLine: state.originalLine,
originalColumn: state.originalColumn,
};
if (chars.length > 0) {
state.namesIndex += decodeVLQInteger(chars); // optional 5th field: names index
mapping.namesIndex = state.namesIndex;
}
return mapping;
}
4. Write parseMappings
// Full mappings parser: returns array of per-line arrays of mapping objects
function parseMappings(mappingsString) {
const state = {
generatedLine: 0,
generatedColumn: 0, // reset per line
sourceIndex: 0,
originalLine: 0,
originalColumn: 0,
namesIndex: 0,
};
const lines = mappingsString.split(';'); // each ';' advances the generated line
return lines.map((line, lineIndex) => {
state.generatedLine = lineIndex;
state.generatedColumn = 0; // column resets at every new generated line
if (!line) return []; // empty line segment (no mappings on this generated line)
return line.split(',').map((segment) => {
const chars = segment.split(''); // work on a mutable char array
return decodeSegment(chars, state);
}).filter(Boolean);
});
}
5. Walk through decoding SAAS character by character
The segment SAAS (four characters, four fields — no names index) from a real Webpack output:
// S → index 18 → binary 010010
// continuation bit (bit5): 0 → last char in this group
// lower 5 bits: 10010 = 18; this is the first group so bit0=0 is sign=positive
// magnitude = 18 >> 1 = 9 → field 0 (generated column delta) = +9
// A → index 0 → binary 000000
// continuation: 0; magnitude bits: 0000; sign: 0
// value = 0 → field 1 (source index delta) = 0
// A → same as above → field 2 (original line delta) = 0
// S → +9 → field 3 (original column delta) = +9
// Result: { generatedColumnDelta: 9, sourceIndexDelta: 0, origLineDelta: 0, origColDelta: 9 }
// If running state was { genCol: 0, srcIdx: 0, origLine: 3, origCol: 0 }
// After applying deltas: { genCol: 9, srcIdx: 0, origLine: 3, origCol: 9 }
const result = parseMappings('SAAS');
console.log(JSON.stringify(result, null, 2));
// [
// [{ generatedColumn: 9, sourceIndex: 0, originalLine: 9, originalColumn: 9 }]
// ]
// (originalLine is 9 not 3 because state starts at 0 and line delta is 9 from 'S')
Verification
Compare your decoder’s output against the source-map library’s authoritative implementation:
const sourceMap = require('source-map');
async function verifyDecoder(mapJson, testCoordinates) {
// Reference implementation
const consumer = await new sourceMap.SourceMapConsumer(JSON.stringify(mapJson));
// Your implementation
const decoded = parseMappings(mapJson.mappings);
for (const { genLine, genCol } of testCoordinates) {
const ref = consumer.originalPositionFor({ line: genLine + 1, column: genCol });
// source-map library uses 1-indexed lines; our decoder uses 0-indexed
const lineSegments = decoded[genLine] || [];
const seg = lineSegments.find(s => s.generatedColumn === genCol);
if (!seg) {
console.warn(`No segment at ${genLine}:${genCol}`);
continue;
}
const srcName = mapJson.sources[seg.sourceIndex];
console.assert(
srcName === ref.source,
`Source mismatch at ${genLine}:${genCol}: got ${srcName}, expected ${ref.source}`
);
console.assert(
seg.originalLine === ref.line - 1, // convert ref's 1-indexed line to 0-indexed
`Line mismatch at ${genLine}:${genCol}: got ${seg.originalLine}, expected ${ref.line - 1}`
);
}
consumer.destroy();
console.log('All assertions passed');
}
const testMap = {
version: 3,
sources: ['src/index.ts'],
names: [],
mappings: 'AAAA,SAASA,EAAKC'
};
verifyDecoder(testMap, [
{ genLine: 0, genCol: 0 },
{ genLine: 0, genCol: 9 },
]);
Edge Cases & Gotchas
-
Single-field segments have no sign adjustment for field 0. Field 0 (generated column) is specified as always non-negative. Correct encoders set the sign bit to 0 for this field. If you decode a map where field 0 comes out negative, the encoder has a bug — but your decoder should handle it gracefully rather than throwing.
-
The names index (field 4) is genuinely optional. A 4-field segment and a 5-field segment are both valid; you cannot predict field count from the character count because multi-character VLQ groups expand the byte count without adding fields. Always check
chars.length > 0after reading field 3 before attempting to read field 4. -
Continuation characters increase segment byte length unpredictably. A large original line number (say, line 50000 in a monolithic file) requires a multi-character VLQ group for the delta. This is legal and common in maps from large TypeScript codebases. Parsers that assume one character per field will misalign all subsequent segments in the line.
-
Integer overflow on 32-bit accumulations. JavaScript bitwise operators (
|=,>>) coerce operands to signed 32-bit integers. A valid source map can encode absolute positions (via accumulated deltas) exceeding 2^30 in pathological cases — extremely large generated files. If you build a production-grade decoder, use BigInt for the accumulation or detect whenshift >= 30and switch strategy.
FAQ
Why does the source-map library use 1-indexed lines but the VLQ values are 0-indexed?
The originalLine and originalColumn values decoded from mappings are 0-indexed per the v3 spec. The source-map npm library adds 1 to originalLine before returning it from originalPositionFor to match the convention of text editors and stack trace reporters (which show line 1, not line 0). When comparing against the library, always account for this offset: decoded.originalLine === libraryResult.line - 1.
Can a single segment encode more than 5 fields?
No. The v3 spec defines exactly 1, 4, or 5 fields per segment. A 1-field segment maps a generated position to no original source. A 4-field segment maps to an original position without identifier information. A 5-field segment additionally records the identifier name via the names array index. There is no 2-field or 3-field form.
What is the maximum integer a VLQ sequence can encode?
Theoretically unbounded — you can chain as many continuation characters as needed. In practice, the source-map library caps at 32-bit signed integer range. The practical maximum meaningful value is the total line count of the largest source file, which for any real codebase stays well under 2^20.