How it works
Start from an empty literal and add properties:
// transitions.mjs. Run: node --allow-natives-syntax transitions.mjs
const o = {};
o.x = 1;
o.y = 2;
%DebugPrint(o);
Trimmed output on Node 24.19:
- map: 0x… <Map[56](HOLEY_ELEMENTS)> [FastProperties]
- instance size: 56
- inobject properties: 4
- unused property fields: 2
- back pointer: 0x… <Map[56](HOLEY_ELEMENTS)>
- instance descriptors (own) #2: 0x… <DescriptorArray[2]>
Read it as the blog describes: the Map has a back pointer to the Map before the last transition, which is how the transition chain {} to {x} to {x, y} is linked. Its descriptor array holds two entries, x and y. [FastProperties] means not dictionary mode. The object has room for four in-object properties and uses two (unused property fields: 2). I did not read V8's source for the slack-sizing policy.
Two takeaways. The Map, not the object, holds the names. And a different route to the same keys can land on a different Map.
Edge cases
Whether two objects share a Map depends on the route that built them, not only on their final keys:
// routes.mjs. Run: node --allow-natives-syntax routes.mjs
const literal = { x: 1, y: 2 };
const built = {};
built.x = 1;
built.y = 2;
const built2 = {};
built2.x = 1;
built2.y = 2;
console.log(%HaveSameMap(built, built2)); // → true
console.log(%HaveSameMap(literal, built)); // → false
%DebugPrint showed why in this build: the literal's Map had instance size: 40 with inobject properties: 2, while the incrementally built object's Map had instance size: 56 with inobject properties: 4. I conclude the difference comes from how many in-object slots were allocated for each creation route. V8's blog does not describe this. Treat it as a sign that "the same keys" is a weaker promise than "the same construction code".
Other transitions out of the fast path: delete of a property other than the most recently added one switched to dictionary mode in my run (properties became a NameDictionary, with no in-object properties). The blog's explanation is the cost of keeping descriptor arrays and Maps up to date for objects that get many additions and deletions.
Real-world scenario
A constructor that adds a field conditionally splits one class into two shapes. This pattern is the case study in V8's own Indicium post, where the fix was to always initialise the property.
// vec.mjs. Run: node --allow-natives-syntax --no-lazy-feedback-allocation vec.mjs
class Vec {
constructor(x, y) {
this.x = x;
this.y = y;
if (x < 0) this.isNegative = true; // sometimes adds a field
}
}
function readX(v) {
return v.x;
}
console.log(%HaveSameMap(new Vec(1, 2), new Vec(-1, 2))); // → false
readX(new Vec(1, 2));
readX(new Vec(-1, 2));
%DebugPrint(readX); // → slot #0 LoadProperty POLYMORPHIC
Replacing the if with this.isNegative = x < 0; made %HaveSameMap return true in my run. Any code reading v.x now sees one Map instead of two.
How many Maps tip an IC over? With --no-lazy-feedback-allocation I called function getX(o) { return o.x; } with objects of 1, 2, 3, 4 and 5 distinct shapes and printed the slot after each call:
1 shape → LoadProperty MONOMORPHIC
2-4 → LoadProperty POLYMORPHIC
5 → LoadProperty MEGAMORPHIC
That matches the "up to four" wording in the Wasm post, but for property loads it is my observation on this V8 only.
Trade-offs
The design buys speed for monomorphic code: a Map check and a load at a fixed offset. In return it penalises code that is "dynamic" in practice: many shapes, varying property order, delete. Writing JavaScript that keeps a few stable shapes is not a language rule, it is a bet that the optimizer's assumptions hold. Do it where a profile says a hot path is polymorphic, not everywhere.