Language Runtimes · Dynamic Typing
Property Attributes, Accessors, and the Demotion to Dictionary Mode
Why a plain field and an accessor cannot share one layout slot, how attribute bits and non-extensibility force a representation change, and why that change is one-way in practice.
A property is not a value in a slot. It is a name, an optional value, and three flags: writable, enumerable, and configurable. A property may instead be an accessor, in which case it has no value at all but a getter and a setter. The layout descriptor described in Hidden Classes and the Transition Tree Behind Property Writes models the first kind efficiently, and Monomorphic Inline Caches and the Guard That Makes Them Work shows how a compiled load uses it once the descriptor is known. The reason the descriptor can treat ordinary properties as plain slots is that the three flags almost always hold the same values and the property almost always has a value.
The default for a property created by assignment or by an object literal is writable, enumerable, and configurable. Methods defined in a class body differ in one bit: they are non-enumerable, which keeps them out of Object.keys and out of for...in over the prototype, and the layout records that bit in the same descriptor entry without any change of representation. Code that changes these flags beyond those defaults is doing something unusual, and the runtime’s response is to stop treating the object as an ordinary record.
Why an accessor cannot occupy the same slot
A data property is read by loading from an offset. An accessor property is read by calling a function with the receiver as its argument. These are different operations with different costs and different failure modes: a getter can throw, can have side effects, can return a different value each time, and can recurse. A compiled access that was built around the assumption that offset 24 holds a value cannot be reused when offset 24 holds a function to be invoked.
There are two ways to represent accessors in a layout descriptor, and both are used.
The first stores the getter and setter in the same slot as a data property would store its value, and marks the entry with a flag. Access compiles to a test of that flag and then either a load or an indirect call. This keeps the layout compact and keeps the property’s offset stable across the transition from data to accessor, but it puts a branch on the read path of every access to that property, and the branch is not predictable in the way the layout guard is.
The second gives accessor properties their own storage and refuses to place them in the inline slots at all. The layout records that the property exists and that it is an accessor; the getter and setter live in a separate side table, or the object is converted to a representation where all properties are uniform. This costs a level of indirection on accessor access but leaves data-property access untouched.
Redefining an existing data property as an accessor is the case that forces the decision. The operation changes the meaning of the offset, so the object cannot keep the layout it had: a layout that records “this name is now a getter” cannot be shared with instances that still hold a value there. The object therefore leaves its old layout, either for a new one that marks the property as an accessor and points at the getter, or for a representation in which every property carries its own attributes. Which of the two an engine uses depends on whether accessors are part of its fast descriptor format at all; the dictionary is the fallback that always works.
The flags and where they live
Storing three bits per property in the descriptor is the obvious placement, and it is what engines do for the fast case. The descriptor’s entry for a property holds the name, the offset, and a packed byte of attributes. Reading a property does not consult the bits; writing does, for the writable bit, and enumeration does, for the enumerable bit.
The bits matter most at the boundaries. Object.keys must skip non-enumerable properties, so enumeration walks the descriptor and filters. A write to a non-writable property in strict mode must throw, so the store path checks the bit before writing. delete must refuse on a non-configurable property, so the delete path checks it too.
Because the bits are per-descriptor-entry and descriptors are shared, changing a bit on one instance would change it for every instance that shares the layout. So the change cannot be applied in place. The runtime must either produce a new descriptor for the modified object alone, or move the object to a representation where attributes are stored per-instance. Producing a new descriptor is correct but defeats sharing for the single object that needed the change, and the object’s subsequent additions would follow a path in the transition tree that no other object follows.
This is why attribute changes are treated as a demotion rather than as an extension. Extending a layout preserves the sharing that makes access fast; changing an attribute breaks it for one object without any compensating benefit, since an object with unusual attributes is by construction not on the hot path.
Sealing, freezing, and non-extensibility
Object.preventExtensions sets a flag on the object that forbids adding new properties. Object.seal does that and marks every existing property non-configurable. Object.freeze does both and marks every property non-writable as well.
None of these operations changes any offset. A frozen object’s fields are in the same places as before, and reads through the layout still work. What changes is the behaviour of operations that would have mutated the layout or the values. A write to a frozen object must fail or be ignored; an addition to a sealed object must fail. If the object is in dictionary mode, these checks consult per-property bits. If the object is in fast mode, the flags are on the descriptor and the object’s own header carries the extensibility bit.
The subtlety is that the extensibility flag is per-object while the layout is shared, so the flag cannot live on the descriptor. An object that has been made non-extensible has the same layout as an object that has not, and the guard on a property read cannot distinguish them. Reads are unaffected, which is why this works; writes must check the per-object bit, which is one test on the store path.
Freezing also interacts with the prototype chain. Freezing a prototype object does not freeze objects that inherit from it, and it does not prevent those objects from acquiring own properties with the same names. It does prevent the prototype’s own layout from changing, which is a fact that compiled code can rely on: a site that reads an inherited property through a frozen prototype never needs to re-validate that prototype’s layout.
What “dictionary mode” means concretely
When an object is demoted, its properties move from the descriptor’s inline slots into a hash table keyed by name. The descriptor pointer is either replaced with a marker descriptor that says “this object is a dictionary” or left in place with a bit set, depending on the engine.
The important consequence is that offsets stop existing. A dictionary-backed object’s values are found by hashing, which means the cost of every access to that object rises to the lookup described in The Dictionary Object Model and the Cost of a Name Lookup. It also means the object can support operations the fast layout cannot: removing a property and re-adding it with different attributes, defining many properties with different flags, and iterating in an order that reflects a history of insertions and deletions.
Demotion is triggered by operations that the fast representation cannot express cheaply. The common triggers are deleting a property, redefining a data property as an accessor on an object that already has a shared layout, and defining properties with attributes that differ from the defaults the descriptor records.
Engines usually make demotion one-way. Converting a dictionary-backed object back to the fast representation would require proving that its property set and order match some existing descriptor, and that no compiled code is holding a reference to the old dictionary. The check is possible but the benefit is small, because an object that reached dictionary mode through unusual operations is unlikely to be on a hot path afterwards. The asymmetry is deliberate: promotion is cheap and happens constantly, demotion is expensive and happens rarely, so the runtime optimizes for the direction that occurs.
The boundary in practice
The practical rule that follows is that object shape is decided by construction and should not be revised. Fields belong in the constructor or the literal, in a fixed order, with default values rather than conditional additions. Attribute changes belong to objects that are configured once and then used as records, or to prototypes that are frozen deliberately.
Two failure modes are worth naming because they are invisible at the call site.
The first is a delete in a function that runs on every object. Each such call demotes the object it touches, and if that object is a prototype or a shared record, every access through it thereafter pays the dictionary cost. The delete succeeds, the program is correct, and the only symptom is a slower site elsewhere.
The second is a getter defined on an object that also has data fields in a hot loop. If the getter replaces a data property, the object is demoted, and the loop’s cached offset is stale. If the getter is defined at construction, before any access, the layout records it from the start and the object need never be demoted; accessor access is slower than data access but the rest of the object stays fast.
The distinction between defining an accessor at construction and redefining one later is the difference between a layout that describes the object and a layout that has been abandoned. Runtimes can only speculate on the first.
References
- ECMAScript Language Specification — Ordinary Object Internal Methods and Internal Slots — Ecma International, ECMA-262
- Fast properties in V8 — V8 project
- The Python Language Reference — Data model — Python Software Foundation