ES6 Class Mechanics: Syntactic Sugar Over Prototype-Based Constructors
JavaScript's class syntax, introduced in ECMAScript 2015, provides a cleaner interface for creating object blueprints while maintaining the language's underlying prototypal architecture. Prior to this enhancement, developers relied on constructor functions combined with prototype manipulation to achieve object-oriented patterns.
Consider the traditional ES5 approach for defining an entity type:
function Vehicle(make, year) {
this.manufacturer = make;
this.productionYear = year;
}
Vehicle.prototype.describe = function() {
return `Manufactured by ${this.manufacturer} in ${this.productionYear}`;
};
const sedan = new Vehicle("Toyota", 2020);
console.log(sedan.describe());
The instantiation process involves several implicit steps: creating a new object, binding it as the execution context for the constructor, executing the constructor body to initialize properties, and returning the initialized object.
ES6 introduces the class keyword, which formalizes this pattern into a more structured syntax:
class Vehicle {
constructor(make, year) {
this.manufacturer = make;
this.productionYear = year;
}
describe() {
return `Manufactured by ${this.manufacturer} in ${this.productionYear}`;
}
}
const sedan = new Vehicle("Toyota", 2020);
Despite the different appearance, class Vehicle creates a function where Vehicle.prototype holds the defined methods. Verifying this relationship shows that typeof Vehicle yields "function" and Vehicle === Vehicle.prototype.constructor evaluates to true.
Methods declared within the class body automatically attach to the prototype. You can extend this behavior by directly modifying the prototype object:
Vehicle.prototype.honk = function() {
return "Beep!";
};
Or batch-add functionality using Object.assign():
Object.assign(Vehicle.prototype, {
startEngine() {
return "Engine running";
},
stopEngine() {
return "Engine stopped";
}
});
The constructor method handles instance initialization. If omitted, JavaScript supplies a default empty constructor. Explicitly, the constructor returns this by default, but you can override this to return a differetn object entirely:
class Rental {
constructor() {
return {
type: "temporary",
valid: false
};
}
}
const instance = new Rental();
console.log(instance.type); // "temporary"
Property placement determines ownership. Variables assigned to this inside the constructor become own properties of the instance, while methods defined in the class body reside on the prototype chain:
class Rectangle {
constructor(width, height) {
this.w = width;
this.h = height;
}
area() {
return this.w * this.h;
}
}
const shape = new Rectangle(10, 20);
console.log(shape.hasOwnProperty("w")); // true
console.log(shape.hasOwnProperty("area")); // false
console.log("area" in shape); // true
All instances share identical prototype references, meaning modifications through one instance's __proto__ affect all siblings:
const rect1 = new Rectangle(5, 5);
const rect2 = new Rectangle(8, 8);
rect1.__proto__.perimeter = function() {
return 2 * (this.w + this.h);
};
console.log(rect2.perimeter()); // Available on rect2 as well
Unlike function declarations, class declarations do not hoist. The identifier remains uninitialized until execution reaches the declaration:
// Valid in ES5
const obj = new Legacy();
function Legacy() {}
// Throws ReferenceError in ES6
const item = new Modern();
class Modern {}
For inheritance, ES6 provides the extends keyword and super() function, which invoke the parent class constructor and establish the prototype chain between parent and child prototypes.