JavaScript / Intermediate: modules and asynchronous contracts / Lesson 12
Prototypes, classes, and receiver behavior
Follow property lookup and this through ordinary objects and classes. Share behavior intentionally while protecting invariants and avoiding accidental changes to global prototypes.
On this page
Lesson goals
- Trace own and inherited property lookup.
- Explain receiver-dependent method calls.
- Use classes and composition with explicit invariants.
Lookup follows a chain
Reading a property first considers the object’s own properties, then follows its prototype chain until a matching property or the end is reached. A method can therefore be shared without copying its function onto each instance. A missing own property does not mean the read will be undefined, because an inherited property may satisfy it. Object.hasOwn asks the narrower ownership question. Keep dictionary data separate from inherited behavior, especially when external keys are involved. Avoid changing built-in prototypes to add application helpers; that global mutation can surprise unrelated code and make dependencies difficult to reason about.
Classes organize construction and behavior
A class gives syntax for construction and prototype-based methods with additional language rules. The constructor establishes valid initial state; methods preserve the intended transitions. Public fields are not a substitute for invariants, because callers can assign arbitrary values. Private fields restrict direct access but still need sensible methods and error policies. A private reservation count can prevent accidental outside assignment while reserve checks remaining capacity. Avoid exposing an internal mutable collection through a getter if callers should not bypass those checks. Class syntax does not remove identity or shallow-copy behavior learned for ordinary objects.
Test a stateful object through its public operations
The example starts with capacity two and rejects any reservation that would exceed it by returning false. The available getter derives its answer from private state instead of maintaining a second potentially contradictory counter. The caller receives a primitive, so it cannot edit the internal count through the returned value. This deliberately small model omits cancellation and concurrency; adding them requires new transition rules rather than assuming private fields make every workflow correct. Test the exact capacity boundary and repeated rejection to confirm a failed operation does not change the state.
class Seats {
#used = 0;
constructor(capacity) { this.capacity = capacity; }
reserve() {
if (this.#used >= this.capacity) return false;
this.#used++;
return true;
}
get available() { return this.capacity - this.#used; }
}
const seats = new Seats(2);
console.log(seats.reserve(), seats.reserve(), seats.reserve());
console.log(seats.available);
true true false
0
The third attempt has no effect. Production design should also validate and protect capacity if external mutation is not allowed.
Choose composition when it keeps dependencies clear
Inheritance can reuse a coherent behavioral contract, but deep hierarchies often force unrelated features into a shared ancestor. Composition passes a pricing policy, formatter, or storage adapter to an object that coordinates them. This makes substitution and tests explicit without relying on global prototype patches. A subclass must respect the expectations callers have of its base operations; changing success or error meanings can break substitution even if method names match. Prefer small domain operations and review the actual interaction contract before choosing class inheritance merely to avoid a few repeated lines.
Common errors and fixes
An extracted method reads undefined this
- Why it happens
- The callback call loses the intended receiver.
- How to fix it
- Bind the method or wrap a deliberate receiver call.
- Prevent it next time
- Test methods in the callback context that uses them.
Inherited fields are accepted as input data
- Why it happens
- A property read is confused with own-property validation.
- How to fix it
- Validate an allowed schema and use own-property checks where required.
- Prevent it next time
- Treat external dictionaries as a distinct boundary.
Private state is assumed to guarantee correctness
- Why it happens
- Access control substitutes for transition validation.
- How to fix it
- Validate construction and each allowed operation.
- Prevent it next time
- Test invariants at boundaries and after rejected operations.
Check your understanding
1. Why can obj.method() work while const f=obj.method; f() fails?
Show worked solution
The first ordinary call supplies obj as receiver; the second does not. In a module, this is undefined for an unbound ordinary call. Use obj.method.bind(obj) or () => obj.method() when that receiver is intended, and consider lifecycle ownership of the captured object.
2. How should the Seats example protect a fixed capacity?
Show worked solution
Validate capacity as a nonnegative safe integer in construction and store it in a private field or otherwise expose it read-only by contract. Test invalid construction and ensure callers cannot change the invariant by assigning a public capacity property. Privacy complements validation rather than replacing it.
Key takeaways
- Property lookup and call receiver are separate mechanisms.
- Encapsulation supports invariants only when operations preserve them.