Module 1 established that objects live on the Heap and are accessed by pointer. This module opens the object up: what a property actually is beneath its value, how objects delegate to other objects to form inheritance, and how the four-rules-of-this and the class keyword are just ergonomic layers over that same delegation mechanism.
1. Objects in Memory: Property Descriptors
A property is not just a key-value pair. Internally, every property on an object is backed by a Property Descriptor -- a small metadata record controlling how that property behaves. There are two kinds: data descriptors (value + writable) and accessor descriptors (get/set). Both share two universal flags: configurable and enumerable.
What each descriptor flag actually controls
- value: the actual data stored (data descriptors only)
- writable: if false, assignment to the property silently fails in non-strict mode and throws a TypeError in strict mode -- the property becomes read-only
- enumerable: if false, the property is invisible to for...in, Object.keys(), and JSON.stringify(), but still directly accessible by name
- configurable: if false, the property cannot be deleted, and its descriptor cannot be changed again (with one exception: writable can still be flipped from true to false)
- get / set: functions that intercept reads and writes instead of storing a static value (accessor descriptors only -- mutually exclusive with value/writable)
const config = {};
// Object literal properties default to { writable: true, enumerable: true, configurable: true }
Object.defineProperty(config, 'apiVersion', {
value: 'v2',
writable: false, // read-only
enumerable: true,
configurable: false // cannot be deleted or redefined
});
config.apiVersion = 'v3'; // silently ignored (non-strict) or throws (strict)
console.log(config.apiVersion); // 'v2' -- unchanged
delete config.apiVersion; // fails -- configurable: false
console.log(config.apiVersion); // still 'v2'
// Accessor descriptor: computed on read, validated on write
let _internalCount = 0;
Object.defineProperty(config, 'count', {
get() { return _internalCount; },
set(next) {
if (typeof next !== 'number' || next < 0) {
throw new TypeError('count must be a non-negative number');
}
_internalCount = next;
},
enumerable: true,
configurable: false
});
config.count = 5;
console.log(config.count); // 5
// config.count = -1; // throws TypeError -- validation runs on every write2. Prototypes & Inheritance: Delegation, Not Classes
JavaScript has no classical inheritance at the engine level -- it has prototypal delegation. Two distinct things are easily confused: prototype is a plain property that exists ONLY on functions (specifically, on functions usable as constructors), holding the object that will become the [[Prototype]] of instances created with new. __proto__ is a legacy accessor exposing an object's actual internal [[Prototype]] link -- the object it delegates to when a lookup fails locally.
How property lookup actually walks the chain
- When you access obj.prop, the engine first checks obj's OWN properties
- If not found, it follows obj's internal [[Prototype]] link (exposed as __proto__) to the next object in the chain and checks there
- This repeats until the property is found, or until [[Prototype]] is null (the end of every chain -- Object.prototype's own [[Prototype]] is null)
- This is called Behavior Delegation: an object doesn't 'inherit' methods by copying them -- it delegates the lookup to another live object, so a change to the prototype object is instantly visible to every object delegating to it
- Own properties always shadow (take priority over) delegated ones with the same name -- this is how you 'override' a method without touching the prototype
// Building inheritance BY HAND, with zero use of `class`, to expose the mechanics.
const animalBehaviors = {
// `this` here is determined by the CALL SITE, not by where this function is defined
// -- see section 3. When called as dog.describe(), `this` is `dog`.
describe() {
return `${this.name} makes a sound: ${this.speak()}`;
},
speak() {
return '...';
}
};
const dogBehaviors = Object.create(animalBehaviors); // dogBehaviors.__proto__ === animalBehaviors
dogBehaviors.speak = function () { return 'Woof'; }; // shadows animalBehaviors.speak
function createDog(name) {
const dog = Object.create(dogBehaviors); // dog.__proto__ === dogBehaviors
dog.name = name;
return dog;
}
const rex = createDog('Rex');
console.log(rex.describe()); // 'Rex makes a sound: Woof'
// The chain: rex -> dogBehaviors -> animalBehaviors -> Object.prototype -> null
console.log(Object.getPrototypeOf(rex) === dogBehaviors); // true
console.log(Object.getPrototypeOf(dogBehaviors) === animalBehaviors); // true
console.log(rex.hasOwnProperty('name')); // true -- own property
console.log(rex.hasOwnProperty('speak')); // false -- delegated, found via the chain3. The this Keyword: Four Binding Rules
this is not determined by where a function is defined -- it is determined by HOW the function is called (the call-site), except for arrow functions. There are exactly four rules, and they have a strict precedence order when more than one could apply.
| Rule | Trigger | this resolves to |
|---|---|---|
| New Binding | Called with new Fn() | The newly created object (highest precedence) |
| Explicit Binding | Called via .call(obj), .apply(obj), or .bind(obj) | The object passed as the first argument |
| Implicit Binding | Called as a method: obj.method() | The object the method was called on (obj) |
| Default Binding | Called as a bare function: fn() | undefined in strict mode; the global object in non-strict mode |
'use strict';
function whoAmI() { return this; }
// 4. Default Binding -- bare call, strict mode -> undefined
console.log(whoAmI()); // undefined
// 3. Implicit Binding -- called AS A METHOD of user
const user = { name: 'Ada', whoAmI };
console.log(user.whoAmI().name); // 'Ada'
// A classic trap: extracting the method loses its receiver, falling back to Default Binding
const detached = user.whoAmI;
console.log(detached()); // undefined -- `this` is NOT `user` anymore
// 2. Explicit Binding -- call/apply/bind force `this` regardless of call-site
function greet(greeting) { return `${greeting}, ${this.name}`; }
console.log(greet.call({ name: 'Grace' }, 'Hello')); // 'Hello, Grace'
const boundGreet = greet.bind({ name: 'Linus' });
console.log(boundGreet('Hi')); // 'Hi, Linus'
// 1. New Binding -- highest precedence, even overrides a prior .bind()
function Person(name) { this.name = name; }
const BoundPerson = Person.bind({ name: 'Ignored' });
const p = new BoundPerson('Real Name'); // `new` wins -- creates a fresh object
console.log(p.name); // 'Real Name'// BAD WAY (Don't Do This): losing `this` in a callback with a regular function
class Timer {
constructor() { this.seconds = 0; }
startBad() {
setInterval(function () {
this.seconds++; // `this` here is Default Binding (undefined/global) -- NOT the Timer instance
}, 1000);
}
}
// ARCHITECT WAY (Do This): arrow function captures `this` lexically from startGood()
class TimerFixed {
constructor() { this.seconds = 0; }
startGood() {
setInterval(() => {
this.seconds++; // `this` is resolved via the Scope Chain -> TimerFixed instance
}, 1000);
}
}4. Modern ES6+ Classes: Syntactic Sugar Over Prototypes
class did not add a new inheritance model to JavaScript -- it added a stricter, more ergonomic SYNTAX over the exact same prototype-and-delegation mechanics from section 2. Every method defined in a class body is installed on ClassName.prototype, non-enumerable by default (unlike object-literal methods, which are enumerable) -- a detail the sugar handles for you that manual Object.create chains do not.
class Account {
#balance; // private field -- truly inaccessible from outside, enforced by the engine, not convention
static #minBalance = 0; // private static field, shared across all instances
constructor(owner, openingBalance) {
this.owner = owner;
this.#balance = Math.max(openingBalance, Account.#minBalance);
}
deposit(amount) {
this.#balance += amount;
return this.#balance;
}
get balance() { return this.#balance; } // installed as an accessor on Account.prototype
static open(owner) { return new Account(owner, 0); } // installed on Account itself, not .prototype
}
// THE DESUGARED EQUIVALENT -- what the engine effectively builds:
function AccountManual(owner, openingBalance) {
this.owner = owner;
this._balance = Math.max(openingBalance, 0); // no true privacy without # -- convention only
}
AccountManual.prototype.deposit = function (amount) {
this._balance += amount;
return this._balance;
};
Object.defineProperty(AccountManual.prototype, 'balance', {
get() { return this._balance; },
enumerable: false // class methods/accessors are non-enumerable BY DEFAULT -- literals are not
});
AccountManual.open = function (owner) { return new AccountManual(owner, 0); };
const acc = new Account('Priya', 100);
console.log(acc.deposit(50)); // 150
// console.log(acc.#balance); // SyntaxError outside the class body -- enforced at parse time, not just conventionModule 2 Recap
You now know that properties are governed by descriptors, not bare key-value pairs; that inheritance is live delegation through the [[Prototype]] chain rather than copied blueprints; that this is resolved by one of four precedence-ordered rules at the call-site (except for arrow functions, which use lexical scope resolution instead); and that class, extends, and #private fields are strict, ergonomic syntax compiling down to the exact same prototype mechanics. Module 3 shifts from objects to control flow: the Call Stack, the Event Loop, and how Promises and async/await actually schedule work.