Chapter 23

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

  1. ClassDefinitionEvaluation creates two prototype chains: the instance chain (instance → ClassName.prototype → ParentClass.prototype) and the constructor chain (ClassName → ParentClass), the latter enabling static method inheritance.
  2. 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.
  3. A subclass's this is created by super() calling the parent's [[Construct]]. Accessing this before super() throws ReferenceError. Field initializers (including private fields) execute after super() returns, before the rest of the constructor body.
  4. 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.
  5. 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.
Rate this chapter
4.8  / 5  (6 ratings)

💬 Comments