V8pedia

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:

src/objects/map.h#L170-L253

The essentials:

  • instance_size — how many bytes to allocate for an instance.

  • instance_typeJS_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 (in bit_field2) and the number of own descriptors (in bit_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