HeapObject & Map (hidden classes)
If tagged values are V8's atoms, the Map is its periodic table. A Map — V8's name for a hidden class — describes the shape of an object: its layout, its type, its prototype. This is the data structure that lets a dynamically-typed language reach static-language property-access speeds, and it is the prerequisite for inline caches and every optimizing tier.
::: info Ubiquitous language
Map = hidden class = shape = structure. Not a JavaScript Map. When V8
people say "the map of an object" they mean the metadata object that describes
its fields. Transition: the edge from one Map to another when a property is
added.
:::
Every object starts with its Map
The first word of every HeapObject is a pointer to its Map:
// [map]: Contains a map which contains the object's reflective information.
DECL_GETTER(map, Tagged<Map>)
— src/objects/heap-object.h#L60-L141
So the layout of, say, {x: 1, y: 2} is:
offset 0: map ──► a Map describing "object with fields x, y"
offset 8: properties (overflow store / hash)
offset 16: elements (indexed properties)
offset 24: x ┐ in-object properties, laid out
offset 32: y ┘ exactly where the Map says they are
Crucially, the field values x and y live inline in the object, and the
Map says where. The object itself carries no property names — those are in the
Map's descriptor array. That is why two {x, y} objects can be byte-for-byte
parallel and share one Map.
What a Map holds
The Map's layout is documented in a big comment in the header — worth reading in full:
The essentials:
instance_size— how many bytes to allocate for an instance.instance_type—JS_OBJECT_TYPE,JS_ARRAY_TYPE,STRING_TYPE, … the coarse runtime type.in-object property count & start — where inline fields live.
bit_field/bit_field2/bit_field3— packed flags, including the elements kind (inbit_field2) and the number of own descriptors (inbit_field3).prototype— the object's[[Prototype]].instance_descriptors— the descriptor array, describing each named property.raw_transitions— the root of the transition tree.
Descriptors
The descriptor array holds one entry per named property — key, details, value:
// [kHeaderSize + 0]: first key (an internalized String)
// [kHeaderSize + 1]: first descriptor details (PropertyDetails)
// [kHeaderSize + 2]: first value (constant, or field type)
— src/objects/descriptor-array.h#L72-L150
The PropertyDetails bit-field records the property's kind (data vs
accessor), location (an in-object field vs a constant in the descriptor),
constness, and representation (Smi, Double, HeapObject, or generic
Tagged):
— src/objects/property-details.h
Representation is a performance lever: if a field has only ever held Smis, the
Map records Smi, and the optimizing compilers can emit untagged integer code
for it — until someone stores a double, which generalizes the representation
and transitions the Map.
Transitions
Maps are (almost) immutable. You do not mutate an object's shape in place; you transition it to a new Map. Adding a property follows a transition edge, creating a new Map if one does not already exist:
Map{} ──"x"──► Map{x} ──"y"──► Map{x,y}
│
└──"z"──► Map{x,z}
The edges are stored in the source Map's transition array:
// Layout of map transition entries in full transition arrays.
static const int kEntryKeyIndex = 0;
static const int kEntryTargetIndex = 1;
static const int kEntrySize = 2;
— src/objects/transitions.h#L314-L420
Because the construction sequence obj.x = …; obj.y = … always walks the same
edges, all objects built the same way end up sharing the same final Map. That
shared identity is exactly what an inline cache keys on: "if
this object's Map is the one I saw last time, the field y is at offset 32 — just
load it."
::: warning The cost of shape instability Constructing objects with properties in different orders, or adding properties conditionally, splinters them across different Maps. Inline caches then go polymorphic or megamorphic, and the compilers lose their fast paths. "Use a consistent shape" is the single most important V8 performance rule, and the transition tree is why. :::
Elements kinds
Indexed properties ("elements") are tracked separately, by elements kind:
enum ElementsKind : uint8_t {
PACKED_SMI_ELEMENTS, HOLEY_SMI_ELEMENTS,
PACKED_ELEMENTS, HOLEY_ELEMENTS,
PACKED_DOUBLE_ELEMENTS, HOLEY_DOUBLE_ELEMENTS,
// … sealed/frozen kinds …
DICTIONARY_ELEMENTS,
// … typed-array kinds …
};
— src/objects/elements-kind.h#L105-L160
The kinds form a one-way lattice: an array can only generalize (PACKEDSMI → PACKEDDOUBLE → PACKED → HOLEY → DICTIONARY), never specialize back. Each step costs performance:
PACKED_SMI iterates fastest, stores tightest.
PACKED_DOUBLE stores unboxed doubles (no HeapNumber per element).
HOLEY kinds force hole-checks on every access.
DICTIONARY ("slow elements") falls back to a hash table for sparse arrays.
Writing arr[0] = 1.5 into a Smi array, or delete arr[3], transitions the
elements kind. Keeping arrays packed and monotyped keeps them fast.
Fast vs dictionary (slow) properties
When an object accumulates too many properties, or is used like a hash map
(deleting and adding keys dynamically), V8 abandons the Map-described layout and
switches the object to dictionary mode — properties move into a hash table
(raw_properties_or_hash holds a NameDictionary):
— src/objects/js-objects.h#L45-L100
Dictionary mode is flexible but slow: no shared Map, no inline-cacheable offsets,
no compiler specialization. %HasFastProperties(obj) in d8 tells
you which mode an object is in.
See also
Inline caches — how Maps become the key to fast dispatch.
Pointer compression — why the map word is 32 bits.
TurboFan & Maglev — how representation and Map stability feed speculative optimization.