You Don't Know JS Yet: Objects & Classes
Deep Dive into Objects, Prototypes, 'this', and Classes in JavaScript.
My next review covers the second edition book, “Objects & Classes,” from the “You Don’t Know JS Yet” series by Kyle Simpson. This edition replaces the classic “this & Object Prototypes,” diving deep into how JavaScript’s object system really works, from fundamental properties to the class
keyword and beyond. Understanding this is key to truly understanding JS.
Chapter 1: Object Foundations
This introductory chapter firmly establishes the central role of objects in JavaScript, while immediately debunking the common myth that “everything in JS is an object.” While not all values are objects, objects are fundamental data structures – flexible key/value containers – and form the bedrock for understanding prototypes and the class
system, key pillars of the language.
The chapter contrasts this new edition with the first (“this & Object Prototypes”), highlighting a shift in focus to reflect modern JavaScript practices where class
syntax is prevalent, though still built upon prototypes.
Key takeaways on object fundamentals include:
-
Creation: Objects are typically created using literal syntax (
{ ... }
), which is preferred overnew Object()
.const myObj = { key: "value", anotherKey: 123, };
-
Properties: Objects store key/value pairs (properties).
-
Definition: Defined as
propertyName: value
within literals. Values can be literals or computed expressions. “Lazy” evaluation requires wrapping expressions in functions. -
Property Names: Usually treated as strings. Computed property names use
[expression]
. Symbols (Symbol()
) provide unique keys.const dynamicProp = "dynamicKey"; const mySymbol = Symbol("description"); const objWithDynamicProps = { [dynamicProp]: "This key was computed", [mySymbol]: "Value for a symbol key", "key with spaces": true, };
-
Conciseness: ES6 introduced shorthands: concise properties (
{ variable }
) and concise methods (method() { ... }
).const name = "Alice"; const age = 30; const person = { name, // Concise property age, greet() { // Concise method console.log(`Hello, my name is ${this.name}`); }, }; person.greet(); // Output: Hello, my name is Alice
-
Spread (
...
): Used within object literals ({ ...sourceObj }
) to perform a shallow copy of an object’s owned, enumerable properties.const source = { a: 1, b: 2 }; const target = { b: 3, c: 4, ...source }; // target is now { b: 2, c: 4, a: 1 } (source.b overwrites target.b) const shallowCopy = { ...source }; // shallowCopy is { a: 1, b: 2 }
-
Deep Copy: Standardized deep cloning is available via
structuredClone()
.const original = { a: 1, b: { c: 2 } }; const deepCopy = structuredClone(original); deepCopy.b.c = 3; console.log(original.b.c); // 2 (original is unaffected)
-
-
Access:
-
Dot Notation (
.
): Preferred method (myObj.prop
). -
Bracket Notation (
[]
): Required for keys with invalid identifier characters or computed keys (myObj["prop-name"]
,myObj[variable]
).console.log(person.name); // Alice console.log(objWithDynamicProps["key with spaces"]); // true
-
Destructuring (
{ prop } = obj
): Declarative syntax to extract values, optionally renaming and providing defaults.const { name: personName, age: personAge = 25 } = person; console.log(personName); // Alice console.log(personAge); // 30 (default not used as age exists) const { nonExistentProp = "default" } = person; console.log(nonExistentProp); // default
-
Optional Chaining (
?.
/?.[]
): Safely access nested properties.const user = { id: 1, // address: { street: '123 Main St' } // address might be missing }; const street = user.address?.street; // undefined (no error) console.log(street);
-
Boxing: Primitives are temporarily wrapped (“boxed”) when properties are accessed.
const greeting = "Hello"; console.log(greeting.length); // 5 (accessing length property on string primitive)
-
-
Modification:
-
Assignment (
=
): Adds or updates property values.person.age = 31; person.city = "New York"; // Adds new property
-
Object.assign(target, ...sources)
: Copies properties into a target object.const updates = { age: 32, occupation: "Developer" }; Object.assign(person, updates); // person is now { name: 'Alice', age: 32, greet: [Function: greet], city: 'New York', occupation: 'Developer' }
-
Deletion (
delete
): Removes a property.delete person.city; console.log(person.city); // undefined
-
-
Inspection:
-
Existence:
Object.hasOwn(obj, "prop")
is preferred for checking owned properties.in
checks the prototype chain too.console.log(Object.hasOwn(person, "name")); // true console.log(Object.hasOwn(person, "toString")); // false (inherited) console.log("toString" in person); // true (found on prototype chain)
-
Listing Contents: Various methods list keys, values, or entries.
console.log(Object.keys(person)); // ['name', 'age', 'greet', 'occupation'] console.log(Object.values(person)); // ['Alice', 32, [Function: greet], 'Developer'] console.log(Object.entries(person)); // [['name', 'Alice'], ...]
-
-
Usage: Objects often serve as temporary containers, frequently combined with destructuring.
function processCoords({ x, y }) { // Object argument destructured const magnitude = Math.sqrt(x * x + y * y); const normalizedX = x / magnitude; // Return object used as temporary container return { magnitude, normalizedX }; } const point = { x: 3, y: 4 }; // Destructure the returned temporary object const { magnitude } = processCoords(point); console.log(magnitude); // 5
The chapter concludes by framing objects as collections of properties, setting the stage to explore their deeper mechanics in the subsequent chapters.
Chapter 2: How Objects Work
This chapter delves deeper than viewing objects merely as containers, exploring the underlying mechanisms – the Metaobject Protocol (MOP) – that govern their behavior and allow for customization.
1. Property Descriptors:
Every object property has an associated property descriptor, a metaobject defining its characteristics. You can retrieve it using Object.getOwnPropertyDescriptor()
and define/modify properties precisely using Object.defineProperty()
or Object.defineProperties()
.
const myObj = { a: 1 };
const descriptor = Object.getOwnPropertyDescriptor(myObj, "a");
// descriptor: { value: 1, writable: true, enumerable: true, configurable: true }
const anotherObj = {};
Object.defineProperty(anotherObj, "b", {
value: 2,
writable: false, // read-only
enumerable: true,
configurable: false, // cannot be deleted or reconfigured
});
console.log(anotherObj.b); // 2
anotherObj.b = 3; // Fails silently (or throws in strict mode)
console.log(anotherObj.b); // 2
value
: The property’s data value.writable
: Iffalse
, thevalue
cannot be changed with=
.enumerable
: Iffalse
, the property won’t show up infor...in
loops,Object.keys
,Object.entries
, spread (...
), orObject.assign
.configurable
: Iffalse
, the property cannot be deleted, and its descriptor attributes (exceptvalue
ifwritable
is true) cannot be changed.writable
can be changed fromtrue
tofalse
, but not back.
2. Accessor Properties (Getters/Setters):
Instead of a static value
, properties can have getter (get
) and/or setter (set
) functions defined in their descriptor. These functions are invoked automatically on property access (obj.prop
) or assignment (obj.prop = val
).
const user = {
_firstName: "John",
_lastName: "Doe",
get fullName() {
console.log("Getting fullName");
return `${this._firstName} ${this._lastName}`;
},
set fullName(value) {
console.log(`Setting fullName to: ${value}`);
const parts = value.split(" ");
this._firstName = parts[0];
this._lastName = parts[1];
},
};
console.log(user.fullName); // Getting fullName -> John Doe
user.fullName = "Jane Smith"; // Setting fullName to: Jane Smith
console.log(user._firstName); // Jane
3. Object Sub-Types (Arrays & Functions):
-
Arrays: Specialized objects for numerically indexed collections, created with
[]
. They have an auto-updatinglength
property (accessing it is not expensive). Avoid creating “empty slots” by assigning beyond the current length + 1, as they behave inconsistently with some array methods.const list = [1, 2]; list[5] = 6; console.log(list); // [ 1, 2, <3 empty items>, 6 ] console.log(list.length); // 6 console.log(list[3]); // undefined (but it's an empty slot)
-
Functions: Are also objects (“first-class objects”) and can have properties. They have built-in
name
andlength
(number of declared parameters before defaults or rest params) properties. Avoid adding custom properties directly; useMap
orWeakMap
to associate metadata with functions instead.function greet(name, msg = "Hello") { /* ... */ } console.log(greet.name); // "greet" console.log(greet.length); // 1 (only 'name' counts) const functionMetadata = new Map(); functionMetadata.set(greet, { description: "Greets a user" });
4. Object Characteristics (Extensibility, Sealed, Frozen):
-
Extensible: Controls if new properties can be added. Default is
true
.Object.preventExtensions(obj)
makes it non-extensible.const obj = { a: 1 }; Object.preventExtensions(obj); obj.b = 2; // Fails silently (or throws in strict mode) console.log(obj.b); // undefined
-
Sealed: An object that is non-extensible and all its existing properties are non-configurable. You can still change the values of existing properties (if they are writable), but you cannot add new properties, remove existing properties, or change the configurability of existing properties. Use
Object.seal(obj)
.const sealedObj = { a: 1, b: 2 }; Object.seal(sealedObj); sealedObj.a = 10; // Allowed console.log(sealedObj.a); // 10 sealedObj.c = 3; // Fails silently (or throws in strict mode) console.log(sealedObj.c); // undefined delete sealedObj.b; // Fails silently (or throws in strict mode) console.log(sealedObj.b); // 2 // Trying to change configurability will also fail // Object.defineProperty(sealedObj, 'a', { configurable: true }); // Throws Error
-
Frozen: An object that is sealed and all its data properties are non-writable. This is the strictest level of immutability. You cannot add new properties, remove existing properties, change the configurability of existing properties, or change the values of existing data properties. If a property is an accessor (getter/setter), the getter will still work, but setting the value via the setter may throw an error if the setter attempts to modify the object. Use
Object.freeze(obj)
.const frozenObj = { a: 1, b: 2 }; Object.freeze(frozenObj); frozenObj.a = 10; // Fails silently (or throws in strict mode) console.log(frozenObj.a); // 1 frozenObj.c = 3; // Fails silently (or throws in strict mode) console.log(frozenObj.c); // undefined delete frozenObj.b; // Fails silently (or throws in strict mode) console.log(frozenObj.b); // 2 // Trying to change writability or configurability will also fail // Object.defineProperty(frozenObj, 'a', { writable: true }); // Throws Error // Object.defineProperty(frozenObj, 'a', { configurable: true }); // Throws Error
Key Takeaways:
preventExtensions
: Cannot add new properties.seal
: Cannot add new properties, cannot remove existing properties, cannot change configurability of existing properties. Can change values of existing writable properties.freeze
: Cannot add new properties, cannot remove existing properties, cannot change configurability of existing properties, cannot change values of existing data properties.
5. The [[Prototype]]
Chain:
This is a crucial, internal linkage ([[Prototype]]
) connecting an object to another object. When a property is accessed on an object and not found, the lookup delegates up this chain. This is how JavaScript implements inheritance (or more accurately, delegation).
- By default, objects created with
{}
ornew Object()
are linked to the built-inObject.prototype
. - Methods like
toString()
andhasOwnProperty()
are typically found onObject.prototype
and accessed via this delegation. Object.hasOwn(obj, prop)
(ES2022) is the preferred, safer static method over the inheritedobj.hasOwnProperty(prop)
.
const myObj = { a: 1 };
console.log(myObj.a); // 1 (Own property)
console.log(myObj.toString()); // "[object Object]" (Inherited from Object.prototype)
console.log(Object.hasOwn(myObj, "a")); // true
console.log(Object.hasOwn(myObj, "toString")); // false
6. Creating and Managing Prototypes:
Object.create(protoObj)
: Creates a new object explicitly linked toprotoObj
.{ __proto__: protoObj, ... }
: Literal syntax using the special__proto__
property (standardized but use with caution, see Appendix B warnings).Object.create(null)
: Creates an object with no prototype linkage, often useful for “dictionary” objects to avoid inheriting fromObject.prototype
.
const parent = { greet: "Hello" };
const child = Object.create(parent);
child.name = "Child";
console.log(child.greet); // "Hello" (Delegated to parent)
const dict = Object.create(null);
dict.key = "value";
console.log(dict.toString); // undefined (No Object.prototype linkage)
7. [[Prototype]]
vs .prototype
Distinction:
This is a common point of confusion:
[[Prototype]]
: The internal link an object has to the object it inherits/delegates from..prototype
: A public property on constructor functions (likeObject
,Array
,MyClass
). When you usenew MyClass()
, the newly created object’s internal[[Prototype]]
link is set to the object referenced byMyClass.prototype
.
function Dog(name) {
this.name = name;
}
// Dog.prototype is an object that will become the [[Prototype]]
// of objects created via 'new Dog()'
Dog.prototype.bark = function () {
console.log("Woof!");
};
const spot = new Dog("Spot");
// spot's internal [[Prototype]] === Dog.prototype
spot.bark(); // Woof! (Delegated to Dog.prototype)
Chapter 3: Classy Objects
This chapter transitions from the underlying object mechanics to the class-oriented design pattern in JavaScript, focusing primarily on the modern class
syntax introduced in ES6. While acknowledging the older “prototypal class” patterns, the chapter emphasizes that class
is the current standard, offering much more than just syntactic sugar over prototypes, although it still leverages the [[Prototype]]
mechanism internally.
1. When to Use Classes:
Class-orientation is suitable when:
- You can classify entities using “is-a” relationships (e.g., a
Car
is aVehicle
). - You need multiple instances of a defined type (class).
- You want to leverage inheritance to share and override characteristics between related classes (subclassing).
- You benefit from encapsulation (hiding internal details) and composition (building objects from other objects). If you only need a single instance of a complex object, other patterns (like modules or plain objects) might be simpler.
2. Basic class
Syntax:
Classes can be defined using declarations or expressions. They contain methods defined without the function
keyword. All code inside a class
body runs in strict mode.
// Class Declaration
class Point2d {
// Constructor: Called when 'new Point2d()' is used
constructor(x, y) {
console.log("Point2d instance created");
// 'this' refers to the new instance being created
this.x = x; // Instance property (member)
this.y = y;
}
// Method: Defined on Point2d.prototype
toString() {
return `(${this.x}, ${this.y})`;
}
}
// Creating an instance
const p1 = new Point2d(5, 10);
console.log(p1.toString()); // Output: (5, 10)
// Class Expression (named)
const Vec = class Vector {
/* ... */
};
// Class Expression (anonymous)
const Anon = class {
/* ... */
};
- The
constructor
is a special method for initialization. If omitted, a default empty one is provided. - Class constructors must be called with
new
. - Methods defined in the class body (like
toString
) are placed on theClassName.prototype
object and shared by all instances via[[Prototype]]
delegation.
3. Instance Members (this
and Public Fields):
-
Inside the
constructor
and methods,this
refers to the current instance. Properties added viathis.prop = value
become instance members. -
Public Fields: A declarative syntax allows defining instance members directly in the class body. These are effectively initialized at the start of the
constructor
(or more accurately, just aftersuper()
in subclasses).class Rectangle { // Public fields with initializers width = 0; height = 0; color = "blue"; calculatedArea = this.width * this.height; // Can reference other fields constructor(width, height) { this.width = width; this.height = height; this.calculatedArea = this.width * this.height; // Re-calculate if needed } getArea() { return this.width * this.height; } } const r1 = new Rectangle(10, 5); console.log(r1.width); // 10 console.log(r1.color); // blue console.log(r1.calculatedArea); // 50 (after constructor runs)
-
Anti-Pattern Warning: Avoid defining methods using arrow functions assigned to public fields (
myMethod = () => { ... }
). This creates a new function per instance instead of a shared method on the prototype, defeating a key benefit of classes and potentially causing performance/memory overhead. Use regular method syntax and understandthis
binding (covered later).class BadPattern { value = 10; // Avoid this: Creates a function per instance getValueArrow = () => this.value; // Prefer this: Method shared on prototype getValueMethod() { return this.value; } } const bp = new BadPattern(); console.log(Object.hasOwn(bp, "getValueArrow")); // true (Oops!) console.log(Object.hasOwn(bp, "getValueMethod")); // false (Good!)
4. Class Inheritance (extends
and super
):
-
The
extends
keyword creates a subclass that inherits from a base class. -
Subclasses inherit methods and properties (via the
[[Prototype]]
chain). -
Methods and fields can be overridden in the subclass.
-
super.method()
calls the overridden method from the parent class. -
super()
must be called within the subclassconstructor
before accessingthis
. It calls the parent class’s constructor. Field initializers in the subclass run aftersuper()
.class Square extends Rectangle { constructor(size) { console.log("Square constructor start"); // Must call parent constructor before 'this' super(size, size); // Calls Rectangle's constructor console.log("Square constructor after super"); this.color = "red"; // Overrides inherited field } // Overrides Rectangle's getArea getArea() { console.log("Using Square's getArea"); return this.width * this.width; } getParentArea() { // Call parent's getArea method return super.getArea(); } } const sq = new Square(7); // Logs constructor messages console.log(sq.getArea()); // Using Square's getArea -> 49 console.log(sq.getParentArea()); // 49 (super.getArea uses this.width/height from sq) console.log(sq.color); // red
-
instanceof
checks if an object exists anywhere in a class’s prototype chain.obj.constructor === ClassName
checks for direct instantiation. -
Inheritance is sharing via
[[Prototype]]
, not copying properties down.
5. Static Properties and Methods (static
):
-
static
defines properties/methods directly on the constructor function itself, not on the prototype or instances. Useful for utility functions or constants related to the class. -
this
inside static methods refers to the class (constructor function). -
Static members are inherited by subclasses (via
[[Prototype]]
linkage of constructors) and can be overridden usingstatic
and accessed viasuper.
in the subclass. -
Static initialization blocks (
static { ... }
) (ES2022) allow more complex static property setup.class Circle { // Static property static PI = 3.14159; // Static method static calculateArea(radius) { // 'this' here refers to the Circle class return this.PI * radius * radius; } // Static block static { console.log("Circle class static block initialized."); this.defaultRadius = 1; } radius; constructor(radius) { this.radius = radius; } } console.log(Circle.PI); // 3.14159 console.log(Circle.calculateArea(10)); // 314.159 console.log(Circle.defaultRadius); // 1
6. Private Class Members (#
):
-
ES2022 introduced private instance fields (
#field
), methods (#method()
), static fields (static #staticField
), and static methods (static #staticMethod()
) using the#
prefix. -
Motivation: Encapsulation, Principle of Least Privilege (hiding implementation details).
-
Syntax:
#
is part of the name; only accessible within the class body where defined. -
Limitation: Privates are not inherited or accessible by subclasses. This is a major difference from
protected
visibility in other languages and can complicate subclassing. JS currently lacksprotected
. -
Brand Check: Use
#privateIdentifier in objectInstance
to safely check if an object possesses a specific private member without causing an error. -
Exfiltration: Private members/methods can still be leaked (e.g., by assigning
this.#privateMethod
to an external variable or passing it as a callback). -
Static Private Gotcha: When calling an inherited public static method from a subclass, if that method tries to access a private static member using
this.#privateStatic
, it fails. Workaround: UseClassName.#privateStatic
inside the static method instead ofthis.#privateStatic
.class Counter { #count = 0; // Private instance field static #maxCount = 10; // Private static field increment() { if (this.#count < Counter.#maxCount) { this.#count++; this.#log(); // Call private instance method } } #log() { // Private instance method console.log(`Current count: ${this.#count}`); } getCount() { return this.#count; } // Ergonomic brand check example static isCounter(obj) { return #count in obj; } } const c = new Counter(); c.increment(); // Current count: 1 // console.log(c.#count); // SyntaxError: Private field '#count' must be declared in an enclosing class console.log(Counter.isCounter(c)); // true console.log(Counter.isCounter({})); // false
Chapter 4: This Works
This chapter tackles the often-misunderstood this
keyword in JavaScript, emphasizing its dynamic nature. Unlike lexical scope, this
is not determined at author-time but rather at runtime, based entirely on how a function is called (the call-site). Understanding this is crucial, as a single this
-aware function (any function using the this
keyword) can behave differently depending on its invocation context.
Key Concepts:
-
Dynamic Context:
this
provides a dynamic context mechanism, contrasting with the static context of lexical scope (closures). It allows a function to operate on different context objects during different invocations. -
The Burden of
this
: Usingthis
requires authors and readers to analyze every call-site to understand a function’s behavior, adding complexity. Use it intentionally when its dynamic power outweighs this cost. -
The Four Binding Rules (in order of precedence): JS determines
this
for a regular function call based on these rules:-
1. New Binding (
new
): When a function is called withnew
, a new object is created,[[Prototype]]
-linked, and set as thethis
for the call. The function usually returns this new object implicitly.function ConstructorFn(val) { this.a = val; // implicitly returns 'this' } const obj1 = new ConstructorFn(1); console.log(obj1.a); // 1
-
2. Explicit Binding (
call
,apply
): These methods invoke a function, explicitly setting thethis
context to the first argument passed tocall
/apply
.function printA() { console.log(this.a); } const obj2 = { a: 2 }; const obj3 = { a: 3 }; printA.call(obj2); // 2 ('this' is obj2) printA.call(obj3); // 3 ('this' is obj3)
-
3. Implicit Binding (Context Object): When a function is called as a method via a containing object reference (
obj.method()
), that object (obj
) becomes thethis
for the call.const obj4 = { a: 4, printA: printA, // Reference to the function above }; obj4.printA(); // 4 ('this' is obj4)
-
4. Default Binding: If none of the above rules apply (e.g., a plain
func()
invocation):- In strict mode (most common, recommended):
this
isundefined
. Accessing properties on it (this.prop
) throws aTypeError
. - In non-strict mode:
this
defaults to the global object (globalThis
, e.g.,window
). This can lead to accidental global variable creation and is generally discouraged.
"use strict"; printA(); // TypeError: Cannot read properties of undefined (reading 'a') // --- In non-strict mode --- // printA(); // Logs globalThis.a (likely undefined, or pollutes global scope)
- In strict mode (most common, recommended):
-
-
Arrow Functions (
=>
) and Lexicalthis
:- Arrow functions are irregular: they do not have their own
this
binding. - Inside an arrow function,
this
behaves like a normal lexical variable, inheriting its value from the nearest enclosing regular function scope. call
,apply
, andbind
have no effect on thethis
value of an arrow function. They also cannot be used withnew
.- This “lexical
this
” behavior is the primary reason arrow functions were introduced, providing a cleaner alternative tovar self = this
orvar context = this
hacks.
const obj5 = { a: 5, regularFn: function () { // 'this' here is obj5 when called like obj5.regularFn() setTimeout(() => { // Arrow function inherits 'this' from regularFn's scope console.log(this.a); // Logs 5, because 'this' is obj5 }, 10); }, brokenFn: function () { setTimeout(function () { // Regular function gets default binding (undefined in strict mode) // console.log(this.a); // Throws TypeError }, 10); }, }; obj5.regularFn();
- Arrow functions are irregular: they do not have their own
-
The Callback Problem: When passing a
this
-aware method as a callback (e.g., event handlers), the originalthis
context is often lost because the calling library/framework invokes the function using a different rule (often explicit binding to something else, or default binding).class Handler { message = "Clicked!"; handleClick() { console.log(this.message); // Relies on 'this' being the Handler instance } setupListener(button) { // Problem: When button is clicked, 'this' inside handleClick // will likely NOT be the Handler instance. // button.addEventListener('click', this.handleClick); // 'this' will be wrong // Solution 1: Arrow function wrapper (Lexical this) button.addEventListener("click", (evt) => this.handleClick(evt)); // Solution 2: bind() // button.addEventListener('click', this.handleClick.bind(this)); } }
-
Hard Binding (
bind
):func.bind(thisContext)
creates a new function that is permanently bound tothisContext
. Itsthis
cannot be overridden by implicit or explicit binding (thoughnew
binding can still override it, surprisingly). -
Critique of Over-Binding: The chapter strongly cautions against patterns like defining class methods as arrow function fields (
myMethod = () => {...}
) or using.bind(this)
in constructors for all methods just to avoid dealing with dynamicthis
.- Costs: Creates functions per instance (memory/performance overhead), defeats prototype sharing, adds complexity.
- Argument: If fixed context is always needed, the closure pattern (using lexical scope without
this
) is often simpler, more appropriate, and more performant than forcingclass
methods to behave like closures. Usethis
when its dynamic nature is beneficial.
// Author argues this is often better than forcing 'this' with => or bind function createPoint(px, py) { let x = px; // Lexical variables, not 'this' let y = py; return { getDoubleX() { return x * 2; }, // Closure keeps access to x toString() { return `(${x}, ${y})`; }, // Closure keeps access to x, y }; } const p_alt = createPoint(3, 4); const getX_alt = p_alt.getDoubleX; console.log(getX_alt()); // 6 (Works simply via closure)
-
Irregular Invocations:
- Indirect Calls: Using comma operator (
(1, obj.method)(...)
) or other tricks can cause a function reference to lose its implicit context, resulting in default binding. - Tagged Templates:
tagFn`...`
results in default binding fortagFn
.obj.tagFn`...`
results in implicit binding (this
isobj
) fortagFn
.
- Indirect Calls: Using comma operator (
Conclusion:
this
is a powerful but complex feature providing dynamic context. Understanding the four binding rules and the lexical behavior of arrow functions is essential. Use this
deliberately, leveraging its dynamic capabilities when needed, but consider alternatives like closure when fixed context is the primary goal, especially within class
structures. Be aware of the costs and complexities involved.
Chapter 5: Delegation
This chapter presents delegation as a powerful, alternative design pattern inherent in JavaScript’s prototypal nature, contrasting it with traditional class-based inheritance. While acknowledging its non-mainstream status, Kyle argues that understanding delegation deepens one’s grasp of JS’s core [[Prototype]]
and this
mechanisms, even if primarily using class
.
1. Deconstructing class
and new
:
The chapter starts by separating object creation from initialization. new
handles creation (step 1) and prototype linking (step 2), while the constructor
only handles initialization (step 3).
2. Factory Functions:
Objects can be created and initialized using simple factory functions without class
or new
.
// Example Prototype Object
const PointActions = {
init(x, y) {
this.x = x;
this.y = y;
},
toString() {
return `(${this.x}, ${this.y})`;
},
};
// Factory Function using Object.create + init
function createPoint(x, y) {
// Step 1 & 2: Create object linked to PointActions
const instance = Object.create(PointActions);
// Step 3: Initialize
instance.init(x, y);
// Step 4: Return
return instance;
}
const p1 = createPoint(3, 4);
console.log(p1.toString()); // (3, 4)
Object.create(protoObj)
performs the creation and[[Prototype]]
linking steps.- An
init()
method (or similar) handles initialization, often called immediately after creation. - Using
new
with such factory functions is wasteful as it creates an extra, unused object. - A generic
make(ObjType, ...args)
helper can simplify the create-then-initialize pattern.
3. Delegation Pattern Explained:
Instead of hierarchical classes, delegation focuses on peer objects cooperating to accomplish tasks. Behavior isn’t copied down; it’s accessed via delegation through the [[Prototype]]
chain or explicit calls, sharing context via dynamic this
.
- Explicit Delegation: Using
call()
orapply()
to invoke a method from one object in the context of another. - Implicit Delegation: Using
[[Prototype]]
linkage so that method calls on an object naturally delegate up the chain if the method isn’t found locally.
const Coordinates = {
setXY(x, y) {
this.x = x;
this.y = y;
},
};
const Inspect = {
toString() {
return `(${this.x}, ${this.y})`;
},
};
// point uses explicit delegation
const point = {};
Coordinates.setXY.call(point, 3, 4); // Explicitly delegate setXY to run with 'point' as 'this'
console.log(Inspect.toString.call(point)); // (3, 4) - Explicitly delegate toString
// anotherPoint uses implicit delegation for setXY
const anotherPoint = Object.create(Coordinates); // [[Prototype]] linked to Coordinates
anotherPoint.setXY(5, 6); // Implicitly delegates to Coordinates.setXY with 'anotherPoint' as 'this'
console.log(Inspect.toString.call(anotherPoint)); // (5, 6) - Still needs explicit for Inspect
This approach achieves virtual composition – objects behave as if composed together at runtime through this
context sharing, without needing a predefined class structure. This aligns with the OLOO (Objects Linked to Other Objects) philosophy.
4. Composing Peer Objects Example:
A more complex example involving Canvas
, Coordinates
, and ControlPoint
objects demonstrates intricate interactions:
- Objects remain distinct peers (
Canvas
,Coordinates
,ControlPoint
). ControlPoint
links toCoordinates
(__proto__
orObject.create
).- Methods on one object (
ControlPoint.render
) explicitly delegate (Canvas.renderScene.call(this)
) to methods on another (Canvas
), sharing theControlPoint
instance asthis
. - Methods called via
this
(this.draw()
insideCanvas.renderScene
) resolve dynamically based on the currentthis
context (ControlPoint
in this case), finding the method onControlPoint
.
This dynamic interplay allows flexible composition and behavior sharing without rigid inheritance hierarchies.
5. Why Use this
for Delegation?
The alternative to implicit this
context sharing is explicitly passing the context object as an argument to every relevant function.
// Explicit Context Passing (Alternative, often more verbose)
var CoordinatesExplicit = {
setXY(entity, x, y) {
// 'entity' is the explicit context, instead of 'this'
entity.x = x;
entity.y = y;
// entity.render(); // Assuming entity has render
},
};
var pointExplicit = { x: 0, y: 0 };
CoordinatesExplicit.setXY(pointExplicit, 3, 4); // Pass context explicitly
While feasible, this explicit passing clutters function signatures and call-sites. The author argues that leveraging dynamic this
for delegation, despite its learning curve, can lead to cleaner, less verbose code by implicitly managing the shared context.
Conclusion:
Delegation, using [[Prototype]]
linkage and dynamic this
, offers a powerful, JS-native alternative to class-based patterns. It emphasizes composing behavior through cooperating peer objects rather than rigid inheritance. While less common, understanding this pattern provides valuable insight into JavaScript’s core object model and enables flexible, dynamic code structures.
Conclusion: Mastering JavaScript’s Object Model
This journey through “YDKJS Yet: Objects & Classes” (2nd Edition) provides a comprehensive deep dive into the heart of JavaScript’s object system. Starting with the fundamental building blocks of objects and their properties, we explored the crucial underlying mechanics like property descriptors and the Metaobject Protocol (MOP) that dictate their behavior.
The book masterfully unravels the often-confusing this
keyword, replacing guesswork
with a clear set of binding rules determined by function call-sites, while also
clarifying the distinct “lexical this” behavior of arrow functions. We then saw how
the [[Prototype]]
chain serves as the true engine for behavior delegation (often
called inheritance) in JavaScript.
Building on this foundation, the review covered the modern class
syntax, examining its features like constructor
, extends
, super
, public/private fields (#
), and static
members. Crucially, the book consistently links this syntax back to the underlying prototypal mechanisms, ensuring we understand how it works, not just that it works.
Finally, Kyle Simpson challenges conventional thinking by presenting delegation (OLOO - Objects Linked to Other Objects) as a powerful, native pattern. This perspective leverages prototypes and dynamic this
directly, offering a compelling alternative to traditional class hierarchies and showcasing the flexibility inherent in JavaScript’s core design.
#JavaScript #Objects #Prototypes #This #Classes #YDKJS #BookReview