Object-Oriented Programming Basics

45 min read Intermediate

Learn the foundations of OOP in JavaScript: classes, objects, constructors, and the principles that make code reusable and organized.

What is OOP?

Object-Oriented Programming (OOP) is a programming paradigm that organizes code into objects—collections of data (properties) and functionality (methods) that work together.

Core OOP Principles
  • Encapsulation: Bundle data and methods that work on that data
  • Abstraction: Hide complex implementation, expose simple interface
  • Inheritance: Create new classes based on existing ones
  • Polymorphism: Objects of different types can be used interchangeably

Constructor Functions

Before ES6 classes, JavaScript used constructor functions to create objects. They're still valid and help understand how classes work under the hood.

JavaScript
// Constructor function (convention: PascalCase)
function Person(name, age) {
    // 'this' refers to the new object being created
    this.name = name;
    this.age = age;
    
    // Method (not recommended here - see prototypes)
    this.greet = function() {
        console.log(`Hi, I'm ${this.name}`);
    };
}

// Create instances with 'new'
const person1 = new Person("Alice", 25);
const person2 = new Person("Bob", 30);

console.log(person1.name); // "Alice"
console.log(person2.age);  // 30
person1.greet(); // "Hi, I'm Alice"

// Check instance type
console.log(person1 instanceof Person); // true

// Without 'new', 'this' would be undefined (strict) or global
// const broken = Person("Test", 20); // Don't do this!

Prototype Methods

Adding methods to the prototype is more memory-efficient, as all instances share the same method.

JavaScript
function Person(name, age) {
    this.name = name;
    this.age = age;
}

// Add method to prototype - shared by all instances
Person.prototype.greet = function() {
    console.log(`Hi, I'm ${this.name}`);
};

Person.prototype.haveBirthday = function() {
    this.age++;
    console.log(`Happy birthday! Now ${this.age} years old`);
};

const alice = new Person("Alice", 25);
const bob = new Person("Bob", 30);

alice.greet(); // "Hi, I'm Alice"
bob.greet();   // "Hi, I'm Bob"

// Both use the same function
console.log(alice.greet === bob.greet); // true

// Check prototype chain
console.log(alice.__proto__ === Person.prototype); // true
console.log(Object.getPrototypeOf(alice) === Person.prototype); // true (preferred)

ES6 Classes

ES6 introduced the class syntax, providing cleaner syntax for creating objects and handling inheritance. It's syntactic sugar over prototypes.

JavaScript
class Person {
    // Constructor - called when creating new instance
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
    
    // Method - automatically on prototype
    greet() {
        console.log(`Hi, I'm ${this.name}`);
    }
    
    haveBirthday() {
        this.age++;
        console.log(`Happy birthday! Now ${this.age} years old`);
    }
    
    // Getter - access like a property
    get info() {
        return `${this.name}, age ${this.age}`;
    }
    
    // Setter - assign like a property
    set info(value) {
        const [name, age] = value.split(", ");
        this.name = name;
        this.age = parseInt(age);
    }
}

// Create instance
const alice = new Person("Alice", 25);
alice.greet(); // "Hi, I'm Alice"

// Using getter
console.log(alice.info); // "Alice, age 25"

// Using setter
alice.info = "Alicia, 26";
console.log(alice.name); // "Alicia"
console.log(alice.age);  // 26

Static Properties and Methods

Static members belong to the class itself, not to instances. They're useful for utility functions and class-level data.

JavaScript
class MathUtils {
    // Static property
    static PI = 3.14159;
    
    // Static method
    static square(x) {
        return x * x;
    }
    
    static cube(x) {
        return x * x * x;
    }
    
    static circleArea(radius) {
        return MathUtils.PI * MathUtils.square(radius);
    }
}

// Access without creating instance
console.log(MathUtils.PI);           // 3.14159
console.log(MathUtils.square(5));    // 25
console.log(MathUtils.circleArea(3)); // 28.274...

// Cannot access on instance
const utils = new MathUtils();
// utils.square(5); // Error!

// Practical example: Factory pattern
class User {
    static count = 0;
    
    constructor(name, email) {
        this.id = ++User.count;
        this.name = name;
        this.email = email;
    }
    
    // Factory method
    static createGuest() {
        return new User("Guest", "guest@example.com");
    }
    
    static createAdmin(name, email) {
        const admin = new User(name, email);
        admin.isAdmin = true;
        return admin;
    }
    
    static getCount() {
        return User.count;
    }
}

const user1 = new User("Alice", "alice@mail.com");
const user2 = User.createGuest();
const admin = User.createAdmin("Bob", "bob@mail.com");

console.log(User.getCount()); // 3
console.log(user1.id); // 1
console.log(user2.id); // 2
console.log(admin.isAdmin); // true

Private Fields and Methods

Private class members (prefixed with #) can only be accessed within the class. This enables true encapsulation.

JavaScript
class BankAccount {
    // Private fields (must be declared)
    #balance = 0;
    #transactionHistory = [];
    
    constructor(owner, initialBalance = 0) {
        this.owner = owner;  // Public
        this.#balance = initialBalance;
        this.#log("Account created");
    }
    
    // Private method
    #log(message) {
        this.#transactionHistory.push({
            date: new Date(),
            message
        });
    }
    
    // Public methods to interact with private data
    deposit(amount) {
        if (amount <= 0) {
            throw new Error("Amount must be positive");
        }
        this.#balance += amount;
        this.#log(`Deposited $${amount}`);
        return this.#balance;
    }
    
    withdraw(amount) {
        if (amount > this.#balance) {
            throw new Error("Insufficient funds");
        }
        this.#balance -= amount;
        this.#log(`Withdrew $${amount}`);
        return this.#balance;
    }
    
    // Getter for private field
    get balance() {
        return this.#balance;
    }
    
    getHistory() {
        // Return copy to prevent modification
        return [...this.#transactionHistory];
    }
}

const account = new BankAccount("Alice", 100);
console.log(account.owner);    // "Alice" (public)
console.log(account.balance);  // 100 (via getter)

account.deposit(50);
account.withdraw(30);

// Cannot access private fields directly
// console.log(account.#balance); // SyntaxError!
// account.#log("hacked"); // SyntaxError!

console.log(account.getHistory());
// [{ date: ..., message: "Account created" }, ...]
Private Fields vs Convention

Before # private fields, developers used _underscore prefix as a convention to indicate "private" properties. However, these are still accessible—only # provides true privacy.

Inheritance

Inheritance allows a class to extend another, inheriting its properties and methods while adding or overriding functionality.

JavaScript
// Parent/Base class
class Animal {
    constructor(name) {
        this.name = name;
    }
    
    speak() {
        console.log(`${this.name} makes a sound`);
    }
    
    eat() {
        console.log(`${this.name} is eating`);
    }
}

// Child class extends parent
class Dog extends Animal {
    constructor(name, breed) {
        // Must call super() first in constructor
        super(name);
        this.breed = breed;
    }
    
    // Override parent method
    speak() {
        console.log(`${this.name} barks!`);
    }
    
    // Add new method
    fetch() {
        console.log(`${this.name} fetches the ball`);
    }
}

class Cat extends Animal {
    constructor(name, color) {
        super(name);
        this.color = color;
    }
    
    speak() {
        console.log(`${this.name} meows!`);
    }
    
    // Call parent method with super
    eat() {
        super.eat(); // Call Animal's eat()
        console.log("*purrs contentedly*");
    }
}

// Usage
const dog = new Dog("Buddy", "Golden Retriever");
const cat = new Cat("Whiskers", "Orange");

dog.speak();  // "Buddy barks!"
cat.speak();  // "Whiskers meows!"

dog.eat();    // "Buddy is eating"
cat.eat();    // "Whiskers is eating" + "*purrs contentedly*"

dog.fetch();  // "Buddy fetches the ball"
// cat.fetch(); // Error - Cat doesn't have fetch()

// Check inheritance
console.log(dog instanceof Dog);    // true
console.log(dog instanceof Animal); // true
console.log(cat instanceof Dog);    // false

Polymorphism

Polymorphism allows objects of different classes to be treated as objects of a common parent class, each responding appropriately to the same method call.

JavaScript
class Shape {
    constructor(name) {
        this.name = name;
    }
    
    // Method to be overridden
    area() {
        throw new Error("Method 'area()' must be implemented");
    }
    
    describe() {
        console.log(`This is a ${this.name} with area ${this.area()}`);
    }
}

class Circle extends Shape {
    constructor(radius) {
        super("circle");
        this.radius = radius;
    }
    
    area() {
        return Math.PI * this.radius ** 2;
    }
}

class Rectangle extends Shape {
    constructor(width, height) {
        super("rectangle");
        this.width = width;
        this.height = height;
    }
    
    area() {
        return this.width * this.height;
    }
}

class Triangle extends Shape {
    constructor(base, height) {
        super("triangle");
        this.base = base;
        this.height = height;
    }
    
    area() {
        return 0.5 * this.base * this.height;
    }
}

// Polymorphism in action
const shapes = [
    new Circle(5),
    new Rectangle(4, 6),
    new Triangle(3, 8)
];

// Same method call, different behavior
shapes.forEach(shape => {
    shape.describe();
});
// "This is a circle with area 78.539..."
// "This is a rectangle with area 24"
// "This is a triangle with area 12"

// Calculate total area - works with any Shape
function totalArea(shapes) {
    return shapes.reduce((sum, shape) => sum + shape.area(), 0);
}

console.log(totalArea(shapes)); // ~114.54

Composition Over Inheritance

Sometimes composition (having objects contain other objects) is more flexible than inheritance. JavaScript supports this pattern well.

JavaScript
// Behaviors as objects (mixins)
const canFly = {
    fly() {
        console.log(`${this.name} is flying!`);
    }
};

const canSwim = {
    swim() {
        console.log(`${this.name} is swimming!`);
    }
};

const canWalk = {
    walk() {
        console.log(`${this.name} is walking!`);
    }
};

// Factory function that composes behaviors
function createDuck(name) {
    return {
        name,
        ...canFly,
        ...canSwim,
        ...canWalk,
        quack() {
            console.log(`${name} says quack!`);
        }
    };
}

function createPenguin(name) {
    return {
        name,
        ...canSwim,
        ...canWalk,
        // Penguins can't fly!
    };
}

function createFish(name) {
    return {
        name,
        ...canSwim,
        // Fish can only swim
    };
}

const donald = createDuck("Donald");
donald.fly();   // "Donald is flying!"
donald.swim();  // "Donald is swimming!"
donald.quack(); // "Donald says quack!"

const pingu = createPenguin("Pingu");
pingu.walk();   // "Pingu is walking!"
pingu.swim();   // "Pingu is swimming!"
// pingu.fly(); // undefined - penguins can't fly

// Class-based composition
class Engine {
    constructor(horsepower) {
        this.horsepower = horsepower;
    }
    
    start() {
        console.log(`Engine with ${this.horsepower}hp started`);
    }
}

class Car {
    constructor(make, model, horsepower) {
        this.make = make;
        this.model = model;
        // Composition: Car HAS-A Engine
        this.engine = new Engine(horsepower);
    }
    
    start() {
        console.log(`Starting ${this.make} ${this.model}`);
        this.engine.start();
    }
}

const car = new Car("Toyota", "Camry", 200);
car.start();
// "Starting Toyota Camry"
// "Engine with 200hp started"
When to Use Each
  • Inheritance: "Is-a" relationship (Dog IS-A Animal)
  • Composition: "Has-a" relationship (Car HAS-A Engine)
  • Favor composition when you need flexibility
  • Use inheritance for clear hierarchies

Prototypal Inheritance Deep Dive

JavaScript's inheritance model is prototype-based, not class-based. Understanding prototypes is key to mastering JavaScript OOP.

JavaScript
// Every object has an internal [[Prototype]] link
const animal = {
    alive: true,
    breathe() {
        console.log("Breathing...");
    }
};

// Object.create() creates object with specified prototype
const dog = Object.create(animal);
dog.bark = function() {
    console.log("Woof!");
};

console.log(dog.alive);     // true (inherited from animal)
dog.breathe();              // "Breathing..." (inherited)
dog.bark();                 // "Woof!" (own property)

// Checking the prototype chain
console.log(Object.getPrototypeOf(dog) === animal);  // true
console.log(dog.__proto__ === animal);                // true (deprecated but works)

// hasOwnProperty vs in operator
console.log(dog.hasOwnProperty("bark"));   // true (own)
console.log(dog.hasOwnProperty("alive"));  // false (inherited)
console.log("alive" in dog);               // true (checks chain)

// The prototype chain
const poodle = Object.create(dog);
poodle.breed = "Poodle";

console.log(poodle.breed);   // "Poodle" (own)
console.log(poodle.bark);    // function (from dog)
console.log(poodle.alive);   // true (from animal)
// Chain: poodle -> dog -> animal -> Object.prototype -> null

// Object.create with property descriptors
const car = Object.create(null, {  // null = no prototype
    brand: {
        value: "Toyota",
        writable: true,
        enumerable: true,
        configurable: true
    },
    start: {
        value: function() { console.log("Starting..."); },
        writable: false
    }
});

console.log(car.toString);  // undefined (no Object.prototype!)

// Setting prototype dynamically
const proto = { greet() { return "Hello!"; } };
const obj = { name: "Test" };

Object.setPrototypeOf(obj, proto);
console.log(obj.greet());  // "Hello!"
// Warning: setPrototypeOf is slow, avoid in performance-critical code

Mixins - Sharing Behavior

Mixins allow you to copy properties from multiple sources, enabling multiple inheritance-like behavior.

JavaScript
// Basic mixin pattern
const canSwim = {
    swim() {
        console.log(`${this.name} is swimming`);
    }
};

const canFly = {
    fly() {
        console.log(`${this.name} is flying`);
    }
};

const canWalk = {
    walk() {
        console.log(`${this.name} is walking`);
    }
};

// Apply mixins to a class
class Animal {
    constructor(name) {
        this.name = name;
    }
}

class Duck extends Animal {}

// Object.assign copies properties
Object.assign(Duck.prototype, canSwim, canFly, canWalk);

const donald = new Duck("Donald");
donald.swim();  // "Donald is swimming"
donald.fly();   // "Donald is flying"
donald.walk();  // "Donald is walking"

// Mixin factory function
function withLogging(Base) {
    return class extends Base {
        log(message) {
            console.log(`[${this.constructor.name}] ${message}`);
        }
    };
}

function withTimestamp(Base) {
    return class extends Base {
        get timestamp() {
            return new Date().toISOString();
        }
    };
}

// Compose mixins
class Service {}
class LoggedService extends withTimestamp(withLogging(Service)) {}

const service = new LoggedService();
service.log("Started");         // "[LoggedService] Started"
console.log(service.timestamp); // "2024-01-15T10:30:00.000Z"

// Functional mixin pattern
const withEventEmitter = (superclass) => class extends superclass {
    #listeners = new Map();
    
    on(event, callback) {
        if (!this.#listeners.has(event)) {
            this.#listeners.set(event, []);
        }
        this.#listeners.get(event).push(callback);
        return this;
    }
    
    emit(event, ...args) {
        const callbacks = this.#listeners.get(event) || [];
        callbacks.forEach(cb => cb(...args));
        return this;
    }
};

class Button extends withEventEmitter(HTMLElement) {}

// Mixin conflict resolution
const mixin1 = { method() { return "one"; } };
const mixin2 = { method() { return "two"; } };

// Last one wins with Object.assign
Object.assign(Target.prototype, mixin1, mixin2);
new Target().method();  // "two"

// Explicit composition for control
class Target {
    method() {
        return mixin1.method.call(this) + "-" + mixin2.method.call(this);
    }
}

Object.create Patterns

Object.create provides more control than classes for prototype-based patterns.

JavaScript
// Factory pattern with Object.create
const personProto = {
    greet() {
        return `Hello, I'm ${this.name}`;
    },
    
    introduce() {
        return `${this.greet()}, aged ${this.age}`;
    }
};

function createPerson(name, age) {
    const person = Object.create(personProto);
    person.name = name;
    person.age = age;
    return person;
}

const alice = createPerson("Alice", 30);
console.log(alice.introduce()); // "Hello, I'm Alice, aged 30"

// OLOO (Objects Linking to Other Objects) pattern
const Widget = {
    init(width, height) {
        this.width = width;
        this.height = height;
        return this;  // Enable chaining
    },
    
    render() {
        console.log(`Widget: ${this.width}x${this.height}`);
    }
};

const Button = Object.create(Widget);
Button.setup = function(width, height, label) {
    this.init(width, height);
    this.label = label;
    return this;
};
Button.render = function() {
    Widget.render.call(this);  // Call "super"
    console.log(`Label: ${this.label}`);
};

const submitBtn = Object.create(Button).setup(100, 40, "Submit");
submitBtn.render();
// "Widget: 100x40"
// "Label: Submit"

// Dictionary object (no prototype pollution)
const dict = Object.create(null);
dict.key = "value";
console.log("toString" in dict);  // false (clean!)

// Immutable prototype pattern
const immutableProto = Object.freeze({
    type: "immutable",
    describe() {
        return `This is ${this.type}`;
    }
});

const obj = Object.create(immutableProto);
obj.name = "Test";
// immutableProto.type = "mutable"; // Error in strict mode

// Delegation pattern
const calculatorMethods = {
    add(a, b) { return a + b; },
    subtract(a, b) { return a - b; },
    multiply(a, b) { return a * b; }
};

function Calculator() {
    return Object.create(calculatorMethods);
}

const calc = Calculator();
console.log(calc.add(5, 3));      // 8
console.log(calc.multiply(4, 2)); // 8

OOP Pattern Guidelines

  • Classes: Best for clear hierarchies and familiar syntax
  • Object.create: When you need fine-grained prototype control
  • Mixins: For sharing behavior across unrelated classes
  • Composition: When inheritance doesn't fit (favor this!)
  • OLOO: For simpler, prototype-based design without classes

Summary

Classes

Blueprints for creating objects with shared structure

Constructor

Special method called when creating instances

Static

Members on the class itself, not instances

Private (#)

True encapsulation for internal data

Inheritance

Extend classes with extends and super

Composition

Combine behaviors for flexible design