Difficulty: Intermediate
ES6 classes, introduced in 2015, provide a cleaner and more familiar syntax for creating objects and managing inheritance in JavaScript. Under the hood, classes are syntactic sugar over JavaScript's existing prototypal inheritance - the class keyword does not introduce a new object model. A class declaration creates a function (the constructor), and methods defined in the class body are placed on that function's prototype property, exactly as you would do manually with constructor functions.
The constructor method is a special method that runs when you create a new instance with the new keyword. Each class can have at most one constructor. Inside it, you initialize instance properties using this. If you do not define a constructor, JavaScript provides a default empty one. For subclasses, the default constructor calls super(...args), forwarding all arguments to the parent constructor.
Static methods and properties belong to the class itself, not to instances. You declare them with the static keyword. They are useful for utility functions that are logically related to the class but do not operate on instance data. For example, a User class might have a static fromJSON(json) method that creates a User from a JSON string. Static methods cannot access this to reference instance data - this inside a static method refers to the class itself.
Private fields and methods, denoted by the # prefix, are a relatively recent addition (ES2022). They are truly private - they cannot be accessed or detected from outside the class body. This is enforced by the JavaScript engine, not by convention. Before private fields existed, developers used the underscore prefix (_property) as a naming convention to signal "do not access directly," but this provided no actual enforcement. Private fields solve this problem definitively.
Getters and setters let you define properties that execute a function when read or written. They are defined using the get and set keywords inside a class body. This is powerful for validation, computed properties, or lazy initialization. From the outside, they look like normal property access - the caller does not know a function is running behind the scenes.
Class expressions are an alternative to class declarations. Just like function expressions, you can assign an anonymous or named class to a variable. Named class expressions have their name scoped to the class body, which can be useful for self-referencing. Class expressions are particularly useful when you need to create classes dynamically or pass them as arguments to functions.
class Car {
constructor(make, model, year) {
this.make = make;
this.model = model;
this.year = year;
this.speed = 0;
}
accelerate(amount) {
this.speed += amount;
return `${this.make} ${this.model} is going ${this.speed} km/h`;
}
brake(amount) {
this.speed = Math.max(0, this.speed - amount);
return `${this.make} ${this.model} slowed to ${this.speed} km/h`;
}
toString() {
return `${this.year} ${this.make} ${this.model}`;
}
}
const car = new Car('Toyota', 'Camry', 2024);
console.log(car.toString());
console.log(car.accelerate(60));
console.log(car.accelerate(40));
console.log(car.brake(30));
// Proof: methods live on the prototype
console.log(typeof Car.prototype.accelerate);
The constructor initializes instance properties. Methods like accelerate and brake are defined on Car.prototype, shared across all instances. toString overrides Object.prototype.toString for a custom string representation.
class MathUtils {
static PI = 3.14159265;
static circleArea(radius) {
return MathUtils.PI * radius * radius;
}
static factorial(n) {
if (n <= 1) return 1;
return n * MathUtils.factorial(n - 1);
}
static clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
}
console.log(MathUtils.PI);
console.log(MathUtils.circleArea(5).toFixed(2));
console.log(MathUtils.factorial(5));
console.log(MathUtils.clamp(15, 0, 10));
// Static methods are NOT on instances
const m = new MathUtils();
console.log(typeof m.circleArea);
Static members are accessed via the class name, not instances. They serve as namespace-scoped utilities. Attempting to call a static method on an instance returns undefined because the method is on the constructor function, not on its prototype.
class BankAccount {
#balance; // private field
#transactionLog; // private field
constructor(owner, initialBalance) {
this.owner = owner; // public
this.#balance = initialBalance;
this.#transactionLog = [];
}
#log(type, amount) { // private method
this.#transactionLog.push({
type,
amount,
date: new Date().toISOString().split('T')[0]
});
}
deposit(amount) {
if (amount <= 0) throw new Error('Amount must be positive');
this.#balance += amount;
this.#log('deposit', amount);
return this.#balance;
}
withdraw(amount) {
if (amount > this.#balance) throw new Error('Insufficient funds');
this.#balance -= amount;
this.#log('withdrawal', amount);
return this.#balance;
}
get balance() {
return this.#balance;
}
get history() {
return [...this.#transactionLog]; // return copy
}
}
const acct = new BankAccount('Alice', 1000);
console.log(acct.balance);
console.log(acct.deposit(500));
console.log(acct.withdraw(200));
console.log(acct.history.length);
// Cannot access private fields from outside
try {
console.log(acct.#balance);
} catch (e) {
console.log(e.message);
}
Private fields (#balance, #transactionLog) and private methods (#log) cannot be accessed from outside the class body. The getter 'balance' provides controlled read access. The history getter returns a copy to prevent external mutation of the internal log.
// Class expression assigned to a variable
const Temperature = class {
#celsius;
constructor(celsius) {
this.#celsius = celsius;
}
get celsius() {
return this.#celsius;
}
set celsius(value) {
if (value < -273.15) {
throw new Error('Temperature below absolute zero');
}
this.#celsius = value;
}
get fahrenheit() {
return this.#celsius * 9 / 5 + 32;
}
set fahrenheit(value) {
this.celsius = (value - 32) * 5 / 9; // uses the setter for validation
}
toString() {
return `${this.#celsius.toFixed(1)}°C / ${this.fahrenheit.toFixed(1)}°F`;
}
};
const temp = new Temperature(100);
console.log(temp.toString());
console.log(temp.fahrenheit);
temp.fahrenheit = 32;
console.log(temp.celsius);
console.log(temp.toString());
try {
temp.celsius = -300;
} catch (e) {
console.log(e.message);
}
This class expression demonstrates getters and setters for computed and validated properties. Setting fahrenheit internally uses the celsius setter, which enforces the absolute zero constraint. From the caller's perspective, these look like plain properties.
ES6 classes, constructor, static methods, private fields, getters setters, class expressions