Under the Hood of class: ClassDefinitionEvaluation and Private Fields
class is syntactic sugar, but not simple syntactic sugar. ClassDefinitionEvaluation does far more than function + prototype handwriting can replicate when it comes to setting up the prototype chain, registering methods, and handling private fields. Private fields in particular operate entirely outside the prototype chain.
🔹 Level 1 · What You Need to Know
The Basic Structure of a Class
class Animal {
#name // private field declaration (must appear at the top of the class body)
constructor(name) {
this.#name = name // private field assignment
}
speak() { // instance method (defined on the prototype)
return `${this.#name} makes a sound`
}
static create(name) { // static method (defined on the class itself)
return new Animal(name)
}
get name() { // getter (defined on the prototype)
return this.#name
}
}
class Dog extends Animal {
constructor(name) {
super(name) // must be called before using 'this'!
this.type = 'dog'
}
speak() {
return `${this.name} barks` // access private field via getter
}
}
const d = new Dog('Rex')
console.log(d.speak()) // 'Rex barks'
console.log(d instanceof Dog) // true
console.log(d instanceof Animal) // true
Where Methods Are Defined
| Syntax | Defined On | Enumerable |
|---|---|---|
method() {} |
ClassName.prototype |
false |
static method() {} |
ClassName |
false |
get prop() {} |
ClassName.prototype |
false |
#privateMethod() {} |
Each instance object | — |
Core Characteristics of Private Fields
#field is truly private, not a convention like _field:
class Secret {
#value = 42
reveal() { return this.#value }
}
const s = new Secret()
console.log(s.reveal()) // 42
console.log(s.#value) // SyntaxError: Private field '#value' must be declared in an enclosing class
console.log(s['#value']) // undefined (this is an ordinary property, not a private field)
console.log('#value' in s) // false (regular 'in' cannot see private fields)
Why super() Must Come Before this
A subclass's this is created and initialized by super(). Using this before calling super() throws a ReferenceError:
class Parent {
constructor() { this.x = 1 }
}
class Child extends Parent {
constructor() {
// console.log(this) // ReferenceError: Must call super constructor before accessing 'this'
super()
console.log(this) // Parent { x: 1 } — this has been initialized by super()
this.y = 2
}
}
🔸 Level 2 · How It Actually Works
The Complete Prototype Structure of class Inheritance
class Animal { ... }
class Dog extends Animal { ... }
Complete prototype chain structure:
Dog (function object) Animal (function object)
┌─────────────────┐ ┌─────────────────┐
│ [[Prototype]] ──│──────────────►│ (Animal fn) │
│ │ │ │
│ prototype ──────│──┐ │ prototype ──────│──┐
└─────────────────┘ │ └─────────────────┘ │
│ │
▼ ▼
Dog.prototype │ Animal.prototype │
┌─────────────────┐ │ ┌─────────────────┐ │
│ [[Prototype]] ──│──│───────────►│ [[Prototype]] ──│──│──► Object.prototype
│ constructor ────│──│──► Dog │ constructor ────│──│──► Animal
│ speak() │ │ │ speak() │ │
└─────────────────┘ │ └─────────────────┘ │
▲ │ ▲ │
│ │ │ │
new Dog() instance │ new Animal() instance│
┌─────────────────┐ │ ┌─────────────────┐ │
│ [[Prototype]] ──│──┘ │ [[Prototype]] ──│──┘
│ type: 'dog' │ │ (own properties) │
│ #name (private) │ │ #name (private) │
└─────────────────┘ └─────────────────┘
Two prototype chains:
1. Instance chain: instance → Dog.prototype → Animal.prototype → Object.prototype
2. Constructor chain: Dog → Animal → Function.prototype
(this allows static methods to be inherited)
ClassDefinitionEvaluation Step by Step
Spec 15.7.14 ClassDefinitionEvaluation is the core algorithm invoked when a class declaration or expression is evaluated. Simplified steps:
ClassDefinitionEvaluation(classBinding, classHeritage, classBody):
Step 1: Create class environment
- Create ClassEnvironment (scope for private names)
- If classBinding present, bind class name in environment
(the name of a class expression is visible inside the body)
Step 2: Handle extends (if present)
- Evaluate classHeritage → superclass
- Verify superclass is a function or null
- Determine protoParent (= superclass.prototype or null)
- Determine constructorParent (= superclass)
Step 3: Create the prototype object
- proto = OrdinaryObjectCreate(protoParent)
i.e., proto.[[Prototype]] = protoParent (superclass's prototype)
Step 4: Initialize the constructor
- If classBody has explicit constructor: use it
- If not:
- Base class: use default constructor() {}
- Derived class: use default constructor(...args) { super(...args) }
- Create function object F via OrdinaryFunctionCreate
- Set F.prototype = proto
- Set proto.constructor = F (non-enumerable)
- If extends present:
- F.[[Prototype]] = superclass (constructor inheritance)
- F.[[ConstructorKind]] = 'derived'
- Otherwise:
- F.[[Prototype]] = Function.prototype
- F.[[ConstructorKind]] = 'base'
Step 5: Process each ClassElement
For each element in classBody:
- Instance methods: PropertyDefinitionEvaluation → defined on proto (enumerable: false)
- Static methods: PropertyDefinitionEvaluation → defined on F (enumerable: false)
- Private methods: registered in PrivateEnvironment
- Instance fields (including private): collected into fields list
- Static fields: evaluated and defined on F immediately
Step 6: Install private methods
- Associate each private method's PrivateName with the method in PrivateEnvironment
Step 7: Initialize static fields
- Evaluate each static field initializer in declaration order
Step 8: Return constructor F
How Private Fields Are Implemented
Private fields are not prototype properties or ordinary properties. They are implemented through PrivateName + [[PrivateElements]]:
Private Field Implementation Structure:
ClassEnvironment (created when class is evaluated):
┌────────────────────────────────────────────┐
│ PrivateEnvironmentRecord │
│ #name → PrivateName { [[Description]]: '#name' } │
│ #count → PrivateName { [[Description]]: '#count' } │
└────────────────────────────────────────────┘
│
│ when each instance is created (new ClassName())
▼
Instance object (OrdinaryObject):
┌────────────────────────────────────────────┐
│ [[PrivateElements]] │
│ [ { [[Key]]: PrivateName(#name), │
│ [[Kind]]: 'field', │
│ [[Value]]: 'Rex' }, │
│ { [[Key]]: PrivateName(#count), │
│ [[Kind]]: 'field', │
│ [[Value]]: 0 } ] │
└────────────────────────────────────────────┘
Accessing obj.#name:
1. Linear search in obj.[[PrivateElements]] for [[Key]] === PrivateName(#name)
2. If found: return [[Value]]
3. If not found: throw TypeError
"Cannot read private member #name from an object whose class did not declare it"
class Dog {
#name
constructor(name) { this.#name = name }
static isInstance(obj) {
return #name in obj // detect private field via 'in' operator (ES2022)
}
}
class Cat {
#name
constructor(name) { this.#name = name }
}
const dog = new Dog('Rex')
const cat = new Cat('Whiskers')
console.log(Dog.isInstance(dog)) // true
console.log(Dog.isInstance(cat)) // false — Cat's #name and Dog's #name are different PrivateName objects
Where Private Methods Are Stored
Private methods differ from public methods: public methods are defined on the prototype (shared by all instances), while private method entries exist in each instance's [[PrivateElements]] (though the function object itself is shared):
Public Method vs Private Method Storage:
Public method speak():
Dog.prototype.speak = function() { ... }
All instances share the same function object via prototype chain
Memory: O(1), independent of instance count
Private method #speak():
Each instance's [[PrivateElements]] contains one entry:
{ [[Key]]: PrivateName(#speak), [[Kind]]: 'method', [[Value]]: <function> }
Although [[Value]] points to the same function object,
each instance carries its own PrivateElement entry
Memory: O(n), proportional to instance count (each entry has overhead)
class WithPrivateMethod {
#secret() { return 42 }
publicCall() { return this.#secret() }
}
const obj = new WithPrivateMethod()
console.log(obj.publicCall()) // 42
console.log(obj['#secret']) // undefined (not accessible this way)
// obj.#secret() // SyntaxError (#secret not in current lexical scope)
The TDZ of class Declarations
class declarations, like let/const, have a Temporal Dead Zone — the declaration is hoisted but cannot be accessed until the declaration statement is executed:
class Declaration Hoisting and TDZ:
Before execution:
┌──────────────────────────────────────┐
│ Variable Environment │
│ Animal → <uninitialized> (TDZ) │
│ Dog → <uninitialized> (TDZ) │
└──────────────────────────────────────┘
After executing class Animal { ... }:
┌──────────────────────────────────────┐
│ Variable Environment │
│ Animal → [Animal function object] │
│ Dog → <uninitialized> (TDZ) │
└──────────────────────────────────────┘
// class declarations have TDZ — cannot use before declaration
new MyClass() // ReferenceError: Cannot access 'MyClass' before initialization
class MyClass {}
// function declarations are fully hoisted — can use before declaration
new MyFunc() // works fine
function MyFunc() {}
🔺 Level 3 · What the Spec Says
15.7 Class Definitions
Spec 15.7 defines the evaluation of class-related syntax. Class declaration runtime semantics reference 15.7.14 ClassDefinitionEvaluation. Key algorithm nodes:
ClassElement Classification (15.7.1):
ClassElement types:
MethodDefinition → instance method
static MethodDefinition → static method
FieldDefinition ; → instance field
static FieldDefinition ; → static field
ClassStaticBlock → static initialization block (ES2022)
; → empty element (ignored)
When Class Fields Are Initialized:
Instance fields (#field = expr, field = expr) are not initialized during ClassDefinitionEvaluation. They are collected as initializers (InitializeInstanceElements) and executed on each new call during [[Construct]]:
Extra steps in [[Construct]] related to classes:
1. Create new object thisArgument
2. Call InitializeInstanceElements(thisArgument, F):
a. Install private methods: for each method in F.[[PrivateMethods]],
add a PrivateElement entry to thisArgument.[[PrivateElements]]
b. Execute field initializers: for each field in declaration order
- Private field #f = expr → add PrivateName → value to [[PrivateElements]]
- Public field f = expr → DefinePropertyOrThrow(thisArgument, 'f', ...)
3. Execute constructor body
PrivateEnvironment Records (Spec 9.2)
PrivateEnvironmentRecord is a new type of environment record (introduced in ES2022) dedicated to managing the scope of private names:
PrivateEnvironmentRecord structure:
{
[[OuterPrivateEnvironment]]: outer PrivateEnvironmentRecord or null,
[[Names]]: list of private names (PrivateName objects)
}
PrivateName object:
{
[[Description]]: '#fieldName' (used in error messages)
}
Note: PrivateName is an object reference — identity comparison (===) checks
reference equality. Dog's #name and Cat's #name are completely different
PrivateName objects, even if their description strings are identical.
This guarantees cross-class access security.
[[PrivateElements]] Storage and Access
Each object instance's [[PrivateElements]] is a List where each entry is a PrivateElement Record:
PrivateElement Record:
{
[[Key]]: PrivateName (a reference, not a string)
[[Kind]]: 'field' | 'method' | 'accessor'
[[Value]]: field value / method function object
(accessor kind has [[Get]] and [[Set]] instead of [[Value]])
}
The private field access operation PrivateGet(P, O) (spec 7.3.26):
PrivateGet(P, O):
// P is a PrivateName, O is an object
1. entry = PrivateElementFind(O, P)
// linear search in O.[[PrivateElements]] for [[Key]] === P
2. If entry is undefined:
throw TypeError ("Cannot read private member of an object
whose class did not declare it")
3. If entry.[[Kind]] === 'field':
return entry.[[Value]]
4. If entry.[[Kind]] === 'method':
return entry.[[Value]] (return the method function directly)
5. If entry.[[Kind]] === 'accessor':
if entry.[[Get]] is undefined: throw TypeError
return Call(entry.[[Get]], O)
The private field write operation PrivateSet(P, O, value) (spec 7.3.27):
PrivateSet(P, O, value):
1. entry = PrivateElementFind(O, P)
2. If entry is undefined: throw TypeError
3. If entry.[[Kind]] === 'field':
entry.[[Value]] = value
4. If entry.[[Kind]] === 'method':
throw TypeError (private methods cannot be assigned)
5. If entry.[[Kind]] === 'accessor':
if entry.[[Set]] is undefined: throw TypeError
Call(entry.[[Set]], O, [value])
The in Operator for Private Fields (Spec 13.10.1)
ES2022 introduced private field in detection (Ergonomic brand checks):
HasPrivateName(O, P):
// Implementation of `#field in obj`
1. entry = PrivateElementFind(O, P)
2. If entry is not undefined: return true
3. return false
This is more precise than instanceof: instanceof checks the prototype chain and can fail with multiple Realms or a modified Symbol.hasInstance; #field in obj directly checks [[PrivateElements]] and is unaffected by the prototype chain.
💎 Level 4 · Edge Cases and Traps
Trap 1: The this Initialization Rule in Subclass Constructors
A subclass's ([[ConstructorKind]] === 'derived') this is created by the parent class's [[Construct]], not the subclass. Before super() returns, this is in an "uninitialized" state:
class Base {
constructor() {
this.baseField = 1
}
}
class Derived extends Base {
#privateField
constructor() {
// accessing this here throws ReferenceError
// console.log(this) // ReferenceError
super()
// after super() returns, this has been initialized by Base's constructor
// and InitializeInstanceElements has already installed private fields
console.log(this.baseField) // 1
this.#privateField = 42 // private field is now accessible
}
}
new Derived()
If a subclass omits the constructor entirely, the default constructor(...args) { super(...args) } is used, forwarding all arguments automatically.
Trap 2: Static Private Fields Are Inaccessible Even to Subclasses
class Parent {
static #secret = 'parent secret'
static getSecret() {
return Parent.#secret // correct: within Parent's lexical scope
}
}
class Child extends Parent {
static getChildSecret() {
// return Child.#secret // SyntaxError: #secret not declared in Child
// Static private fields are NOT inherited!
return Parent.getSecret() // must access via parent's public method
}
}
console.log(Parent.getSecret()) // 'parent secret'
console.log(Child.getChildSecret()) // 'parent secret'
// console.log(Child.#secret) // SyntaxError
Trap 3: #field in obj Is a More Reliable Instance Check Than instanceof
class MyClass {
#brand // private field used as a brand check
constructor() {
this.#brand = true
}
static isInstance(obj) {
return #brand in obj // if the object has this private field, it's a MyClass instance
}
}
// Problem with instanceof: it can be faked
class FakeClass {
static [Symbol.hasInstance](obj) { return true } // spoofing instanceof
}
console.log({} instanceof FakeClass) // true — false positive!
// Cross-realm: instanceof fails
// e.g., arrays from an iframe:
// [] instanceof Array // false (different Realm's Array is a different constructor)
// #brand in obj advantages:
const obj = new MyClass()
console.log(MyClass.isInstance(obj)) // true
console.log(MyClass.isInstance({})) // false
console.log(MyClass.isInstance(null)) // false (doesn't throw, returns false)
Trap 4: Class Methods Are Non-Enumerable; Object Literal Methods Are Enumerable
This difference causes surprises during serialization and for...in traversal:
class Foo {
bar() {}
}
const obj = {
bar() {}
}
// Check enumerability
const classDescriptor = Object.getOwnPropertyDescriptor(Foo.prototype, 'bar')
console.log(classDescriptor.enumerable) // false
const objDescriptor = Object.getOwnPropertyDescriptor(obj, 'bar')
console.log(objDescriptor.enumerable) // true
// Practical impact: for...in does not traverse class methods
for (const key in new Foo()) {
console.log(key) // no output (bar is non-enumerable)
}
for (const key in obj) {
console.log(key) // 'bar' (enumerable)
}
Trap 5: Private Method Memory Overhead Scales with Instance Count
class PublicMethod {
method() { return 42 }
}
class PrivateMethod {
#method() { return 42 }
call() { return this.#method() }
}
// Create 10,000 instances
const publicInstances = Array.from({ length: 10000 }, () => new PublicMethod())
const privateInstances = Array.from({ length: 10000 }, () => new PrivateMethod())
// Public method: all instances share PublicMethod.prototype.method — 1 function object
// Private method: each instance has one PrivateElement entry in [[PrivateElements]]
// Although [[Value]] points to the same function object,
// 10,000 PrivateElement records themselves carry memory overhead
// In memory-sensitive scenarios, weigh the trade-off carefully
// Consider WeakMap-based privacy (the old approach) for high-volume instances
// WeakMap pattern (private-like, no per-instance overhead)
const _method = new WeakMap()
class WeakMapPrivate {
constructor() {
_method.set(this, function() { return 42 })
}
call() { return _method.get(this)() }
}
Chapter Summary
ClassDefinitionEvaluationcreates two prototype chains: the instance chain (instance → ClassName.prototype → ParentClass.prototype) and the constructor chain (ClassName → ParentClass), the latter enabling static method inheritance.- Private fields are implemented via PrivateName +
[[PrivateElements]]. PrivateName is an object reference (not a string); same-named private fields from different classes are completely different PrivateName objects and cannot access each other. - A subclass's
thisis created bysuper()calling the parent's[[Construct]]. Accessingthisbeforesuper()throwsReferenceError. Field initializers (including private fields) execute aftersuper()returns, before the rest of the constructor body. - Static private fields (
static #field) are only visible within the lexical scope of the declaring class. Subclasses cannot inherit or access them, even through instances. - Each instance of a class with private methods carries an independent PrivateElement entry in
[[PrivateElements]], making memory cost O(n) with instance count. Evaluate the impact in scenarios with high-frequency instance creation.