JavaScript Classes: ES6 Class Syntax Guide
JavaScript has always been a prototype-based language, but ES6 introduced class syntax in 2015 to make object-oriented programming more approachable. This wasn't a fundamental change to how...
Key Insights
- ES6 classes are syntactic sugar over JavaScript’s prototypal inheritance, making object-oriented patterns more readable without changing the underlying mechanics
- Private fields using the
#syntax and static methods provide true encapsulation and utility functions that belong to the class itself rather than instances - Classes work best for complex domain models with clear hierarchies, while factory functions and object literals remain better choices for simple data structures and functional patterns
Introduction to ES6 Classes
JavaScript has always been a prototype-based language, but ES6 introduced class syntax in 2015 to make object-oriented programming more approachable. This wasn’t a fundamental change to how JavaScript works—classes are syntactic sugar over the existing prototype system. They provide a cleaner, more familiar syntax for developers coming from class-based languages like Java or C#.
Before ES6, we created objects using constructor functions and manipulated prototypes directly:
// Pre-ES6: Constructor function
function User(name, email) {
this.name = name;
this.email = email;
}
User.prototype.greet = function() {
return `Hello, I'm ${this.name}`;
};
const user = new User('Alice', 'alice@example.com');
The same code with ES6 classes looks like this:
// ES6: Class syntax
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
greet() {
return `Hello, I'm ${this.name}`;
}
}
const user = new User('Alice', 'alice@example.com');
Both approaches create identical objects under the hood. The class syntax simply provides better organization and readability. Use classes when you’re building complex domain models, need inheritance hierarchies, or want clear encapsulation. For simple data structures or one-off objects, stick with object literals or factory functions.
Basic Class Syntax and Constructor
A class declaration starts with the class keyword followed by the class name (conventionally PascalCase). The constructor method is a special function that runs when you create a new instance with the new keyword.
class User {
constructor(name, email, role = 'user') {
this.name = name;
this.email = email;
this.role = role;
this.createdAt = new Date();
}
getProfile() {
return {
name: this.name,
email: this.email,
role: this.role,
memberSince: this.createdAt.toLocaleDateString()
};
}
updateEmail(newEmail) {
if (!newEmail.includes('@')) {
throw new Error('Invalid email format');
}
this.email = newEmail;
}
isAdmin() {
return this.role === 'admin';
}
}
const alice = new User('Alice', 'alice@example.com', 'admin');
console.log(alice.getProfile());
console.log(alice.isAdmin()); // true
Each instance gets its own copy of the properties defined in the constructor, but methods are shared via the prototype chain. This is memory-efficient—if you create 1000 User instances, they all share the same getProfile, updateEmail, and isAdmin methods.
Class Methods and Properties
Classes support several types of methods and properties that give you fine-grained control over your objects.
class BankAccount {
// Private field
#balance = 0;
// Public field
accountNumber;
// Static property
static bankName = 'JavaScript Bank';
static #accountCounter = 0;
constructor(owner, initialDeposit = 0) {
this.owner = owner;
this.#balance = initialDeposit;
this.accountNumber = ++BankAccount.#accountCounter;
}
// Getter
get balance() {
return this.#balance;
}
// Setter
set balance(amount) {
throw new Error('Cannot set balance directly. Use deposit() or withdraw()');
}
// Instance methods
deposit(amount) {
if (amount <= 0) {
throw new Error('Deposit amount must be positive');
}
this.#balance += amount;
return this.#balance;
}
withdraw(amount) {
if (amount > this.#balance) {
throw new Error('Insufficient funds');
}
this.#balance -= amount;
return this.#balance;
}
// Static method
static compareAccounts(account1, account2) {
return account1.balance - account2.balance;
}
}
const checking = new BankAccount('Alice', 1000);
const savings = new BankAccount('Alice', 5000);
checking.deposit(500);
console.log(checking.balance); // 1500 (using getter)
// checking.#balance; // SyntaxError: Private field
console.log(BankAccount.bankName); // 'JavaScript Bank'
console.log(BankAccount.compareAccounts(checking, savings)); // -3500
Private fields (prefixed with #) are truly private—they can’t be accessed outside the class, even through reflection or property enumeration. Static methods and properties belong to the class itself, not instances. Use static methods for utility functions related to the class, like factory methods or comparison functions.
Class Inheritance with extends
Inheritance lets you create specialized versions of a class while reusing common functionality. The extends keyword creates a parent-child relationship, and super lets you call parent methods.
class Animal {
constructor(name, species) {
this.name = name;
this.species = species;
}
makeSound() {
return 'Some generic sound';
}
introduce() {
return `I'm ${this.name}, a ${this.species}`;
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name, 'dog'); // Call parent constructor
this.breed = breed;
}
makeSound() {
return 'Woof! Woof!';
}
fetch() {
return `${this.name} is fetching the ball!`;
}
introduce() {
return `${super.introduce()} of breed ${this.breed}`;
}
}
class Cat extends Animal {
constructor(name, indoor = true) {
super(name, 'cat');
this.indoor = indoor;
}
makeSound() {
return 'Meow';
}
scratch() {
return `${this.name} is scratching the furniture`;
}
}
const dog = new Dog('Buddy', 'Golden Retriever');
const cat = new Cat('Whiskers');
console.log(dog.introduce()); // "I'm Buddy, a dog of breed Golden Retriever"
console.log(dog.makeSound()); // "Woof! Woof!"
console.log(dog.fetch()); // "Buddy is fetching the ball!"
console.log(cat.makeSound()); // "Meow"
console.log(cat.scratch()); // "Whiskers is scratching the furniture"
When overriding methods, you can call the parent implementation with super.methodName(). This is useful when you want to extend behavior rather than completely replace it. Always call super() in the child constructor before accessing this, or you’ll get a ReferenceError.
Advanced Class Features
ES6 classes support several advanced features that handle edge cases and special initialization needs.
// Class expression (unnamed)
const Rectangle = class {
constructor(width, height) {
this.width = width;
this.height = height;
}
get area() {
return this.width * this.height;
}
};
// Named class expression
const Square = class SquareClass {
constructor(side) {
this.side = side;
}
get area() {
return this.side ** 2;
}
};
// Static initialization block
class Configuration {
static #config = {};
static #initialized = false;
static {
// Runs once when class is evaluated
console.log('Initializing configuration...');
this.#config = {
apiUrl: process.env.API_URL || 'http://localhost:3000',
timeout: 5000
};
this.#initialized = true;
}
static get config() {
return { ...this.#config };
}
}
console.log(Configuration.config);
Class expressions are useful when you need to create classes dynamically or pass them as values. Static initialization blocks run once when the class is first evaluated, perfect for complex setup logic that doesn’t fit in a single line.
Common Patterns and Best Practices
Classes introduce some gotchas, especially around method binding and the this keyword.
class EventHandler {
constructor(name) {
this.name = name;
this.count = 0;
}
// Problem: 'this' gets lost when passed as callback
handleClickWrong() {
this.count++;
console.log(`${this.name} clicked ${this.count} times`);
}
// Solution 1: Arrow function (binds 'this' lexically)
handleClickArrow = () => {
this.count++;
console.log(`${this.name} clicked ${this.count} times`);
}
// Solution 2: Bind in constructor
constructor(name) {
this.name = name;
this.count = 0;
this.handleClickBound = this.handleClickWrong.bind(this);
}
}
const handler = new EventHandler('Button');
// This will fail - 'this' is undefined
// setTimeout(handler.handleClickWrong, 1000);
// These work correctly
setTimeout(handler.handleClickArrow, 1000);
setTimeout(handler.handleClickBound, 1000);
Arrow functions in class fields are convenient but create a new function for each instance, using more memory. For methods called frequently as callbacks (like event handlers), this trade-off is usually worth it.
When testing class-based code, focus on behavior rather than implementation details:
// Good: Test behavior
test('BankAccount allows deposits and withdrawals', () => {
const account = new BankAccount('Test', 100);
account.deposit(50);
expect(account.balance).toBe(150);
account.withdraw(30);
expect(account.balance).toBe(120);
});
// Avoid: Testing private implementation
// You shouldn't need to access #balance directly
Choose classes over factory functions when you need inheritance, instanceof checks, or clear encapsulation with private fields. Use factory functions for simpler objects, when you want more flexibility, or when working in a functional programming style.
Conclusion
ES6 classes brought familiar object-oriented syntax to JavaScript without abandoning its prototypal roots. They excel at organizing complex domain logic, providing clear inheritance hierarchies, and encapsulating state with private fields. Modern JavaScript frameworks like React (class components, though less common now), Angular, and Vue all leverage classes extensively.
The key is knowing when to use them. Classes work best for entities with complex behavior and clear relationships. For data structures, configuration objects, or functional patterns, simpler approaches often win. Master both paradigms, and you’ll write more expressive, maintainable JavaScript code.