Skip to main content

Classes and Object-Oriented Programming

Overview

Classes in JavaScript provide a syntactic sugar over JavaScript's prototype-based inheritance. Introduced in ES6 (2015), classes make it easier to create objects with shared behavior and state, enabling object-oriented programming patterns.

What is a Class?

A class is a blueprint for creating objects. It defines properties (data) and methods (behavior) that instances of the class will have.

Basic class syntax
class Animal {
constructor(name) {
this.name = name;
}

speak() {
console.log(`${this.name} makes a sound`);
}
}

const dog = new Animal('Dog');
dog.speak(); // Output: Dog makes a sound

Class Components

  • Constructor: Special method called when creating a new instance. Used to initialize properties
  • Properties: Data stored in the instance
  • Methods: Functions that belong to the class
  • this: Refers to the current instance

Constructors

The constructor() method is called automatically when you create a new instance using the new keyword.

Constructor with multiple parameters
class Person {
constructor(firstName, lastName, age) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}

getFullName() {
return `${this.firstName} ${this.lastName}`;
}

isAdult() {
return this.age >= 18;
}
}

const person = new Person('John', 'Doe', 25);
console.log(person.getFullName()); // John Doe
console.log(person.isAdult()); // true

Instance Properties and Methods

Instance Properties

Properties are stored on each individual instance:

Instance properties
class BankAccount {
constructor(owner, balance = 0) {
this.owner = owner;
this.balance = balance;
}

deposit(amount) {
this.balance += amount;
return this.balance;
}

withdraw(amount) {
if (amount > this.balance) {
console.log('Insufficient funds');
return null;
}
this.balance -= amount;
return this.balance;
}
}

const account = new BankAccount('Alice', 1000);
console.log(account.deposit(500)); // 1500
console.log(account.withdraw(200)); // 1300

Instance Methods

Methods define behavior. They have access to this:

Methods with complex logic
class Car {
constructor(brand, model, speed = 0) {
this.brand = brand;
this.model = model;
this.speed = speed;
}

accelerate() {
this.speed = Math.min(this.speed + 10, 200);
return `${this.brand} ${this.model} is now going ${this.speed} km/h`;
}

brake() {
this.speed = Math.max(this.speed - 10, 0);
return `${this.brand} ${this.model} slowed down to ${this.speed} km/h`;
}
}

const car = new Car('Tesla', 'Model 3');
console.log(car.accelerate()); // Tesla Model 3 is now going 10 km/h
console.log(car.accelerate()); // Tesla Model 3 is now going 20 km/h

Static Methods and Properties

Static members belong to the class itself, not to instances:

Static methods and properties
class MathUtils {
static PI = 3.14159;
static E = 2.71828;

static circleArea(radius) {
return this.PI * radius * radius;
}

static cylinderVolume(radius, height) {
return this.circleArea(radius) * height;
}
}

// Access static members via class name, not instance
console.log(MathUtils.PI); // 3.14159
console.log(MathUtils.circleArea(5)); // 78.53975
console.log(MathUtils.cylinderVolume(5, 10)); // 785.3975

// Instances don't have access to static members
const utils = new MathUtils();
console.log(utils.PI); // undefined

Inheritance

Inheritance allows a class to extend another class, inheriting its properties and methods:

Basic inheritance with extends
class Animal {
constructor(name, species) {
this.name = name;
this.species = species;
}

describe() {
return `${this.name} is a ${this.species}`;
}

makeSound() {
return 'Some generic sound';
}
}

class Dog extends Animal {
constructor(name, breed) {
super(name, 'Dog'); // Call parent constructor
this.breed = breed;
}

makeSound() {
return 'Woof! Woof!';
}

getBreed() {
return `${this.name} is a ${this.breed} dog`;
}
}

const dog = new Dog('Buddy', 'Golden Retriever');
console.log(dog.describe()); // Buddy is a Dog
console.log(dog.makeSound()); // Woof! Woof!
console.log(dog.getBreed()); // Buddy is a Golden Retriever dog

Using super

The super keyword calls the parent class's method:

super for method overriding
class Vehicle {
constructor(brand) {
this.brand = brand;
}

start() {
return `${this.brand} is starting...`;
}
}

class ElectricVehicle extends Vehicle {
constructor(brand, batteryCapacity) {
super(brand);
this.batteryCapacity = batteryCapacity;
}

start() {
// Call parent method and extend it
return super.start() + ' (Battery charging)';
}

chargeBattery(hours) {
return `${this.brand} battery charged for ${hours} hours`;
}
}

const tesla = new ElectricVehicle('Tesla', 100);
console.log(tesla.start()); // Tesla is starting... (Battery charging)
console.log(tesla.chargeBattery(2)); // Tesla battery charged for 2 hours

Getters and Setters

Getters and setters provide controlled access to object properties:

Getters and setters
class Rectangle {
constructor(width, height) {
this._width = width;
this._height = height;
}

get area() {
return this._width * this._height;
}

get perimeter() {
return 2 * (this._width + this._height);
}

set width(value) {
if (value <= 0) {
console.log('Width must be positive');
return;
}
this._width = value;
}

set height(value) {
if (value <= 0) {
console.log('Height must be positive');
return;
}
this._height = value;
}
}

const rect = new Rectangle(10, 20);
console.log(rect.area); // 200 (getter - no parentheses)
console.log(rect.perimeter); // 60

rect.width = 15; // setter
console.log(rect.area); // 300

Private Fields

Private fields (ES2022) are only accessible within the class:

Private fields
class BankAccount {
#balance = 0; // Private field
#pinCode;

constructor(owner, pinCode) {
this.owner = owner;
this.#pinCode = pinCode;
}

deposit(amount) {
if (amount > 0) {
this.#balance += amount;
return `Deposited $${amount}`;
}
}

withdraw(amount, pin) {
if (pin === this.#pinCode && amount <= this.#balance) {
this.#balance -= amount;
return `Withdrew $${amount}`;
}
return 'Transaction failed';
}

getBalance(pin) {
if (pin === this.#pinCode) {
return this.#balance;
}
return 'Invalid PIN';
}
}

const account = new BankAccount('Alice', 1234);
account.deposit(1000);
console.log(account.getBalance(1234)); // 1000
console.log(account.#balance); // SyntaxError: Private field

Encapsulation with Naming Conventions

Before private fields, developers used naming conventions:

Encapsulation with underscore prefix
class User {
constructor(username, password) {
this.username = username;
this._password = password; // Convention: "private" by naming
}

setPassword(newPassword) {
if (newPassword.length < 8) {
console.log('Password must be at least 8 characters');
return false;
}
this._password = newPassword;
return true;
}

verifyPassword(inputPassword) {
return inputPassword === this._password;
}
}

const user = new User('john', 'secretpass');
console.log(user.setPassword('newpass123')); // true

Polymorphism

Polymorphism allows different classes to implement the same method differently:

Polymorphism with inheritance
class Shape {
getArea() {
throw new Error('Method must be implemented');
}
}

class Circle extends Shape {
constructor(radius) {
super();
this.radius = radius;
}

getArea() {
return Math.PI * this.radius * this.radius;
}
}

class Square extends Shape {
constructor(side) {
super();
this.side = side;
}

getArea() {
return this.side * this.side;
}
}

// Polymorphism: same method, different behavior
const shapes = [
new Circle(5),
new Square(4),
new Circle(3)
];

shapes.forEach(shape => {
console.log(shape.getArea().toFixed(2));
});
// Output: 78.54, 16.00, 28.27

Composition vs Inheritance

Composition is often preferable to inheritance:

Composition pattern
class Engine {
start() {
return 'Engine started';
}

stop() {
return 'Engine stopped';
}
}

class GPS {
navigate(destination) {
return `Navigating to ${destination}`;
}
}

class Car {
constructor(brand) {
this.brand = brand;
this.engine = new Engine();
this.gps = new GPS();
}

start() {
return this.engine.start();
}

stop() {
return this.engine.stop();
}

goTo(destination) {
return this.gps.navigate(destination);
}
}

const car = new Car('Toyota');
console.log(car.start()); // Engine started
console.log(car.goTo('Downtown')); // Navigating to Downtown

Abstract Classes Pattern

JavaScript doesn't have native abstract classes, but you can simulate them:

Abstract class pattern
class Animal {
constructor(name) {
if (new.target === Animal) {
throw new Error('Cannot instantiate abstract class');
}
this.name = name;
}

speak() {
throw new Error('Method speak() must be implemented');
}
}

class Dog extends Animal {
speak() {
return `${this.name} barks`;
}
}

// const animal = new Animal('Generic'); // Error
const dog = new Dog('Buddy');
console.log(dog.speak()); // Buddy barks

Common Patterns

Singleton Pattern

Singleton pattern
class Database {
constructor() {
if (Database.instance) {
return Database.instance;
}
this.connection = null;
Database.instance = this;
}

connect() {
this.connection = 'Connected to database';
return this.connection;
}
}

const db1 = new Database();
const db2 = new Database();
console.log(db1 === db2); // true (same instance)

Factory Pattern

Factory pattern
class Animal {
speak() {
throw new Error('Must implement speak()');
}
}

class Dog extends Animal {
speak() { return 'Woof'; }
}

class Cat extends Animal {
speak() { return 'Meow'; }
}

class AnimalFactory {
static createAnimal(type) {
if (type === 'dog') return new Dog();
if (type === 'cat') return new Cat();
throw new Error('Unknown animal type');
}
}

const dog = AnimalFactory.createAnimal('dog');
const cat = AnimalFactory.createAnimal('cat');

Prototypes (Behind the Scenes)

Classes are syntactic sugar over JavaScript's prototype-based model:

Classes and prototypes
class Animal {
constructor(name) {
this.name = name;
}

speak() {
console.log(`${this.name} makes a sound`);
}
}

// Under the hood, methods are added to the prototype
console.log(Animal.prototype.speak); // [Function: speak]
console.log(Animal.prototype.constructor === Animal); // true

const dog = new Animal('Dog');
console.log(Object.getPrototypeOf(dog) === Animal.prototype); // true

Best Practices

  1. Use meaningful class names: Classes should represent real-world entities
  2. Keep classes focused: A class should have a single responsibility
  3. Use private fields for sensitive data: Prevent accidental access
  4. Prefer composition to inheritance: More flexible and maintainable
  5. Validate input in constructors: Ensure valid initial state
  6. Use getters/setters for computed properties: Clean, readable code
  7. Document your classes: Explain purpose and usage
Well-designed class example
class User {
#passwordHash;

constructor(email, passwordHash, role = 'user') {
if (!email.includes('@')) {
throw new Error('Invalid email');
}
this.email = email;
this.#passwordHash = passwordHash;
this.role = role;
this.createdAt = new Date();
}

verifyPassword(password) {
// In real code, use bcrypt or similar
return this.#passwordHash === password;
}

isAdmin() {
return this.role === 'admin';
}

get accountAge() {
return new Date() - this.createdAt;
}
}

const user = new User('john@example.com', 'hashed', 'admin');
console.log(user.isAdmin()); // true
console.log(user.email); // john@example.com

Key Takeaways

  • Classes provide a cleaner syntax for object creation and inheritance
  • Use constructor to initialize instances
  • Static members belong to the class, not instances
  • extends enables inheritance; super calls parent methods
  • Private fields protect internal state
  • Composition is often better than deep inheritance hierarchies
  • Classes are syntactic sugar over prototypes