Hidden classes & object shapes
Learn how JavaScript engines use hidden classes, transition trees, descriptor arrays, fast properties, and migrations to speed up objects.
- 01Explain object layoutsDescribe how maps, shapes, or structures turn dynamic property names into fixed offsets that optimized code can trust after a guard.
- 02Read transitions and storageTrace property-order transitions, descriptor arrays, in-object fields, fast-property backing stores, and dictionary mode.
- 03Use engine facts safelyPredict what
delete,Object.freeze, slack tracking, and representation changes do in V8 12.4 without confusing internals with JavaScript semantics.
Dynamic objects can still get a fixed layout
JavaScript lets you add, update, delete, freeze, and reorder object properties at runtime. That flexibility looks hostile to fast machine code: how can an engine load user.name quickly if every object can be a different bag of names?
A hidden class is an engine's internal layout record for an object. It says which property names exist, what order they were added in, and where their values live, so a checked object can be read by fixed offsets instead of a full search.
V8 calls these records maps. SpiderMonkey talks about shapes. JavaScriptCore talks about structures. The names differ, but the job is similar: give dynamic objects a stable layout that inline caches and optimizers can guard. The Inline caches lesson introduced the guard; here we inspect the layout behind that guard.
We will stay honest about boundaries. JavaScript semantics come from the language: property lookup, descriptors, prototypes, freezing, and deletion. Hidden classes are implementation details. The Node-only probes use V8 12.4 diagnostics; other engines use their own data structures and can change details between releases.
Maps, shapes, and structures
Start with a tiny clue you can see in normal JavaScript: property insertion order. The three tickets below have the same names, but Meera's object inserts platform before name. Browsers do not expose hidden classes, so this snippet only prints keys. The Node probe after it checks V8 maps directly.
function makeTicket(name) { return { name, platform: 2, paid: true };} const asha = makeTicket("Asha");const ravi = makeTicket("Ravi");const meera = { platform: 2, name: "Meera", paid: true }; console.log(Object.keys(asha).join(","));console.log(Object.keys(ravi).join(","));console.log(Object.keys(meera).join(","));%HaveSameMap proofJavaScriptfunction Point(x) { this.x = x;} const a = { x: 1, y: 2 };const b = { x: 3, y: 4 };const c = { y: 2, x: 1 };const p = new Point(1);const q = new Point(2); console.log("node", process.version);console.log("v8", process.versions.v8);console.log("same order", %HaveSameMap(a, b));console.log("different order", %HaveSameMap(a, c));console.log("constructor start", %HaveSameMap(p, q));p.y = 2;console.log("after one transition", %HaveSameMap(p, q));q.y = 3;console.log("after both transition", %HaveSameMap(p, q));The lesson test runs this in Node 22 with V8 12.4. Same property order reports true, different order reports false, and two constructor instances diverge when only one takes an added-property transition, then match after both take it.
| Engine | Common name | What to remember |
|---|---|---|
| V8 | Map, also called a hidden class | First pointer in the object; compares layouts quickly and points at descriptors and transitions. |
| SpiderMonkey | Shape | Represents property layout information used by property lookup and inline caches. |
| JavaScriptCore | Structure | JSC's object-layout structure used by its property access and speculation machinery. |
Primary sources use these names too: the V8 maps documentation introduces maps, descriptor arrays, and transition arrays; Fast properties in V8 explains named properties and elements; and Mathias Bynens and Benedikt Meurer's Shapes and Inline Caches article compares the terminology across engines.
Transition chains and trees
A new empty object starts at a root layout. Adding a new property follows an edge to another layout. Add name, then score, and you reach one map. Add score, then name, and you reach a sibling map. That is why property order matters even when the final set of names matches.
A school uniform tells a teacher where to find each badge. If the same badges appear in a different order, the teacher needs a different layout to find them quickly.
- In real life: A uniform has a name-badge place
- In JavaScript: The root map for a new object
- In real life: Name badge, then house badge
- In JavaScript: A transition chain for
name, thencourse - In real life: House badge, then name badge
- In JavaScript: A sibling branch with a different property order
- In real life: Extra badges make placement less fixed
- In JavaScript: Dictionary mode after heavy property churn
Where the analogy stops: A school chooses uniform rules deliberately. Engines build maps while your code runs and still honor JavaScript property rules.
Follow three objects through a teaching-model transition tree. This records real JavaScript model calls, not browser engine memory.
script
const transitions = new Map();function nextMap(current, property) { const key = current + "->" + property; if (!transitions.has(key)) transitions.set(key, current + "." + property); return transitions.get(key);} function makeRecord(label) { return { label, map: "Root", slots: {} };} function store(record, property, value) { record.map = nextMap(record.map, property); record.slots[property] = value; return record;} store(asha, "name", "Asha");store(asha, "score", 10);const ravi = makeRecord("Ravi");store(ravi, "name", "Ravi");store(ravi, "score", 20);const meera = makeRecord("Meera");store(meera, "score", 30);store(meera, "name", "Meera"); console.log(asha.map === ravi.map);console.log(asha.map === meera.map);Line 19 and line 20 put Asha on the name → score chain. Ravi follows the same chain on lines 22 and 23, so line 28 prints true. Meera takes score → name on lines 25 and 26, so line 29 prints false.
Add x, y, or z to the draft object. Save it, then build another object in the same or a different order.
Descriptor arrays: the property list behind maps
A map does not only say “this object has a shape.” It points at descriptors: metadata for each named property, such as the property name, attributes, representation, and field offset. V8's docs call this structure a DescriptorArray. Transitional maps can share a descriptor array and use a descriptor count as a prefix.
const one = {};one.x = 1;const two = {};two.x = 1;two.y = 2; console.log("ONE");%DebugPrint(one);console.log("TWO");%DebugPrint(two);The test parses the two %DebugPrint listings and checks that the parent and child maps mention the same DescriptorArray[2] address within that one run. It never asserts the address itself.
That shared array is why the word “hidden class” can mislead. The layout record is not a JavaScript class body. It is a compact engine data structure that lets several related maps agree on property metadata while each map represents a different point in the tree.
In-object, fast, and slow properties
V8 separates named properties from array elements. Named properties can live directly inside the object body, or in a separate properties backing store. Both are still “fast properties” when the map and descriptors tell the engine where to look. Slow or dictionary properties trade fixed offsets for flexible updates.
const fast = { name: "Asha", score: 0, active: true };const extended = { name: "Ravi", score: 0, active: true };extended.city = "Pune"; console.log(Object.keys(fast).join(","));console.log(Object.keys(extended).join(","));console.log("city" in extended);| Representation | What it means | Why engines use it |
|---|---|---|
| In-object property | The value lives inside the object body at a fixed field offset. | Fastest common case, but the object cannot grow in place forever. |
| Fast property backing store | The map still describes offsets, but extra values live in a properties array beside the object. | Still fast because descriptors map names to indexes. |
| Dictionary property | Properties move to a hash-table-like dictionary with metadata beside each entry. | Flexible for deletes or many dynamic names, but slower than fixed offsets. |
%HasFastProperties storage probeJavaScriptconst removedMiddle = { a: 1, b: 2, c: 3 };console.log("fast before delete", %HasFastProperties(removedMiddle));delete removedMiddle.b;console.log("fast after delete middle", %HasFastProperties(removedMiddle)); const removedLast = { a: 1, b: 2, c: 3 };delete removedLast.c;console.log("fast after delete last", %HasFastProperties(removedLast)); const many = {};for (let i = 0; i < 80; i += 1) many["p" + i] = i;console.log("fast after many", %HasFastProperties(many)); const frozen = { a: 1, b: 2 };console.log("fast before freeze", %HasFastProperties(frozen));Object.freeze(frozen);console.log("fast after freeze", %HasFastProperties(frozen));console.log("is frozen", Object.isFrozen(frozen));The test proves that a fresh object is fast, deleting a middle or last named property is not fast in this V8 12.4 build, 80 dynamic properties are not fast, and a frozen two-property object can still have fast properties.
Slack tracking trims early extra space
V8 gives new constructor instances a little extra in-object space at first. That is slack tracking: the engine leaves room in case later initialization code adds more fields, then trims unused space after enough constructions. The V8 slack-tracking post describes a construction counter initialized around seven and an extra-property allowance used during this settling period.
A new notebook leaves a few blank lines in case you need more notes. After a few pages, you know the usual amount and stop leaving space.
- In real life: The first page has blank lines
- In JavaScript: Early maps reserve unused in-object fields
- In real life: Later pages use only needed lines
- In JavaScript: Slack tracking trims unused fields
- In real life: Written lines keep their places
- In JavaScript: Existing property offsets remain usable
- In real life: Extra notes need another page
- In JavaScript: Extra properties can move to the backing store
Where the analogy stops: A person chooses notebook space. V8 uses constructor counts and heuristics, and exact numbers are engine details.
function Seat(name) { this.name = name; this.paid = true;} const first = new Seat("Asha");console.log("FIRST");%DebugPrint(first); for (let i = 0; i < 12; i += 1) new Seat("warm" + i);const later = new Seat("Ravi");console.log("LATER");%DebugPrint(later);console.log("same map after trim", %HaveSameMap(first, later));The test parses %DebugPrint before and after warm-up. The first map has more in-object fields and unused property fields; the later map has fewer in-object fields, zero unused fields, and %HaveSameMap(first, later) is still true after trimming.
Dictionary mode, `delete`, and `freeze`
Dictionary mode is not a punishment. It is the representation an engine can choose when fixed offsets are no longer the best fit. Deleting named properties creates holes in the descriptor-based layout. Adding many unpredictable names also starts to look like a dictionary workload. Freezing is different: it changes mutability and extensibility, but V8 can keep fast storage for small completed objects.
const row = { id: 1, name: "Asha", city: "Pune" };delete row.city; const settings = { theme: "dark", compact: false };Object.freeze(settings); console.log(Object.keys(row).join(","));console.log(Object.isFrozen(settings));console.log(settings.theme);const removed = { id: 1, name: "Asha", city: "Pune" };delete removed.city;console.log("delete stays fast", %HasFastProperties(removed)); const settings = { theme: "dark", compact: false };console.log("freeze before", %HasFastProperties(settings));Object.freeze(settings);console.log("freeze after", %HasFastProperties(settings));console.log("frozen map differs", %HaveSameMap({ theme: "dark", compact: false }, settings));const dictionary = { a: 1, b: 2, c: 3 };delete dictionary.b;console.log("DICTIONARY");%DebugPrint(dictionary); const frozen = { a: 1, b: 2 };Object.freeze(frozen);console.log("FROZEN");%DebugPrint(frozen);| Operation | Shape effect | Measured fact |
|---|---|---|
delete obj.middle | In V8 12.4, the object switches to dictionary properties. | %HasFastProperties becomes false in the lesson probe. |
delete obj.last | This Node/V8 build also switches to dictionary properties. | Do not rely on a last-property rollback folklore rule. |
| Add many names dynamically | V8 may choose dictionary mode after enough dynamic additions. | The probe uses 80 names and sees slow properties. |
Object.freeze(obj) | V8 creates a non-extensible frozen map, but the object can stay fast. | %HasFastProperties stays true, while the map is not the old map. |
- A constructor always assigns
name, thenscore, thenactive. - A hot row object deletes
cityafter creation. Object.freezeis called on a small settings object that already has all fields.- A plugin object receives 80 different property names at runtime.
- Two object literals use
{ x, y }in the same order. - A completed config object is frozen before it is shared.
Sort each scenario by the representation the lesson's V8 facts or teaching model suggest.
Shape deprecation and migration
A map can become stale when the field representation it promised is no longer good enough. In V8, changing one object's field from a small integer to a double can create a replacement map. Other objects still on the old map may migrate lazily the next time they are accessed.
Watch a teaching model for shape deprecation: one wider write creates a replacement map, and an old object migrates on next use.
script
const b = { map: "M1:x=Smi", x: 2 };console.log(a.map === b.map); b.x = 1.5;b.map = "M2:x=Double";const deprecated = "M1:x=Smi";console.log(a.map === b.map); if (a.map === deprecated) { a.x = Number(a.x); a.map = "M2:x=Double";}console.log(a.map === b.map);const a = { x: 1 };const b = { x: 2 };console.log("start same", %HaveSameMap(a, b)); b.x = 1.5;console.log("after double same", %HaveSameMap(a, b)); console.log("A BEFORE");%DebugPrint(a);console.log("access", a.x);console.log("A AFTER");%DebugPrint(a);console.log("after access same", %HaveSameMap(a, b));The test sees deprecated_map before a.x is accessed, then stable_map and migration_target after access. %HaveSameMap(a, b) changes from true, to false, back to true.
Practical habits for developers
Most code should not think about hidden classes all day. The useful habits are simple: initialize expected fields in the same order, avoid delete on measured hot objects, prefer null or undefined for “not present yet” when the shape should stay stable, and measure before changing clear code.
function makeCard(name, city) { return { name, city, selected: false, error: null, };} const first = makeCard("Asha", "Pune");const second = makeCard("Ravi", "Kochi");second.error = "missing phone";first.error = null; console.log(Object.keys(first).join(","));console.log(Object.keys(second).join(","));- Use factories or constructors that create the same fields in the same order for many similar objects.
- Set a field to
nullorundefinedinstead of deleting it when the object stays hot. - Keep object shapes boring at the edges of render loops, parsers, and data transforms you have actually profiled.
- Link this layout lesson to inline caches, prototype internals, property descriptors, and freezing and sealing rather than re-learning those topics here.
Common misconceptions
- “Hidden classes are JavaScript classes.” They are engine layout records, not language syntax.
- “Same properties always means same shape.” Order, attributes, representations, prototypes, and transitions can matter.
- “Delete is always optimized away if it removes the last field.” The V8 12.4 probe in this lesson did not show that; both deletes became slow properties.
- “Freeze makes objects slow.” Freezing changes the map, but small frozen objects can still have fast properties.
- “Engine internals should override readability.” Stable shapes are useful only when a measured hot path points there.
| Idea | Means | Do not confuse it with |
|---|---|---|
| JavaScript semantics | Objects have properties, prototypes, descriptors, and observable operations. | Hidden classes are not specified by ECMAScript. |
| Hidden class / map / shape | An engine layout record for property names, attributes, representations, and offsets. | It is not class User {} and not visible to ordinary code. |
| Inline cache | A site-local cache that can use maps/shapes as guards. | This lesson explains the layout that ICs guard, not IC state machines again. |
| Dictionary mode | A flexible property representation for churn-heavy objects. | It does not make your code wrong; it matters only on measured hot paths. |
Practice exercises
Read the teaching model and type the two booleans printed by the final lines.
const transitions = new Map();
function nextMap(current, property) {
const key = current + "->" + property;
if (!transitions.has(key)) transitions.set(key, current + "." + property);
return transitions.get(key);
}
function makeRecord(label) {
return { label, map: "Root", slots: {} };
}
function store(record, property, value) {
record.map = nextMap(record.map, property);
record.slots[property] = value;
return record;
}
const asha = makeRecord("Asha");
store(asha, "name", "Asha");
store(asha, "score", 10);
const ravi = makeRecord("Ravi");
store(ravi, "name", "Ravi");
store(ravi, "score", 20);
const meera = makeRecord("Meera");
store(meera, "score", 30);
store(meera, "name", "Meera");
console.log(asha.map === ravi.map);
console.log(asha.map === meera.map);It prints true and then false: same chain for Asha and Ravi, different branch for Meera.
What V8 structure lists properties and offsets for a map?
DescriptorArray lists the property metadata that maps use.
What does the lesson's V8 probe print for delete stays fast?
The probe prints false for delete stays fast, so the object no longer has fast properties.
Run the factory starter. What key order does it print?
function makeRow(name) {
return { name, score: 0, active: true };
}
console.log(Object.keys(makeRow("Asha")).join(","));function makeRow(name) {
return { name, score: 0, active: true };
}
console.log(Object.keys(makeRow("Asha")).join(","));Every row gets name, score, and active in the same order, so the output is name,score,active.
What three booleans does the migration model print?
const a = { map: "M1:x=Smi", x: 1 };
const b = { map: "M1:x=Smi", x: 2 };
console.log(a.map === b.map);
b.x = 1.5;
b.map = "M2:x=Double";
const deprecated = "M1:x=Smi";
console.log(a.map === b.map);
if (a.map === deprecated) {
a.x = Number(a.x);
a.map = "M2:x=Double";
}
console.log(a.map === b.map);The model prints true false true: shared map, split by widening, shared replacement map after migration.
Name one habit you would apply to a measured hot list or data-transform object.
Good answers include initializing the same fields in the same order, avoiding delete on hot objects, using null instead of delete, keeping shapes consistent, and measuring first.
Check your understanding
Answer by separating language behavior from V8 implementation facts, then trace the small code snippets in order.
Question 1 of 8What is a hidden class or map?
Choose an answer to see the explanation.
Question 2 of 8What does this key-order clue print?
Read the code, then predictconst a = { x: 1, y: 2 }; const b = { y: 2, x: 1 }; console.log(Object.keys(a).join("|")); console.log(Object.keys(b).join("|"));Choose an answer to see the explanation.
Question 3 of 8Why can maps form a transition tree?
Choose an answer to see the explanation.
Question 4 of 8Which statement about descriptor arrays is accurate?
Choose an answer to see the explanation.
Question 5 of 8What does this freeze snippet print?
Read the code, then predictconst settings = { theme: "dark" }; Object.freeze(settings); console.log(Object.isFrozen(settings)); console.log(settings.theme);Choose an answer to see the explanation.
Question 6 of 8In the V8 12.4 probe, what happened after
deleteon a named property?Choose an answer to see the explanation.
Question 7 of 8What does this migration teaching model print?
Read the code, then predictconst a = { map: "M1:x=Smi", x: 1 }; const b = { map: "M1:x=Smi", x: 2 }; console.log(a.map === b.map); b.x = 1.5; b.map = "M2:x=Double"; const deprecated = "M1:x=Smi"; console.log(a.map === b.map); if (a.map === deprecated) a.map = "M2:x=Double"; console.log(a.map === b.map);Choose an answer to see the explanation.
Question 8 of 8Which production habit follows from hidden classes?
Choose an answer to see the explanation.
Key takeaways
- Hidden classes, maps, shapes, and structures are engine layout records for dynamic JavaScript objects.
- Adding properties creates transition chains; same names in different orders can land on different maps.
- Descriptor arrays describe properties and offsets, and maps on a transition chain can share them.
- Fast properties use fixed layouts; dictionary mode handles deletes and heavy property churn.
- Slack tracking trims early unused in-object fields, while representation changes can deprecate and migrate maps.
- Use consistent shapes naturally, avoid hot deletes, and measure before optimizing.
Remember the one-liner.
A hidden class is the engine's shared form template for objects that were built the same way.
You came from Symbols inside the engine. Up next: Fast arrays & elements kinds, where array storage gets its own set of layouts and transitions.