Advanced JavaScript OOP: Prototypes, Patterns & Real Projects

Advanced JavaScript OOP: Prototypes, Patterns & Real Projects

Introduction

If you already know object literals, class, this, and a basic constructor, you are ready for the next layer of JavaScript OOP. Real applications need privacy, flexible reuse, clear interfaces between modules, and designs that survive change—not only syntax that looks object-oriented.

This advanced guide assumes you have completed (or are comfortable with) the beginner JavaScript OOP guide. Here you will dig into how prototypes actually work, build safer encapsulation, prefer composition over deep inheritance, apply polymorphism and a few practical patterns, and finish with a full e-commerce order engine.

How JavaScript OOP really works

Languages like Java are class-based. JavaScript is prototype-based. An ES6 class is mostly cleaner syntax over constructor functions and prototypes.

class User {
  constructor(name) {
    this.name = name;
  }

  greet() {
    return `Hi, ${this.name}`;
  }
}

const u = new User("Aisha");

console.log(typeof User); // "function"
console.log(u.greet === User.prototype.greet); // true
console.log(Object.getPrototypeOf(u) === User.prototype); // true

Three pieces matter:

  • Constructor — the function/class used with new
  • Prototype object — where shared methods live (User.prototype)
  • Instance — the object you create; it links to that prototype

When you call u.greet(), JavaScript finds greet on the prototype if it is not on the instance itself. Understanding that link is the key to advanced OOP in JavaScript.

Deep dive: the prototype chain

Property lookup walks a chain until it finds a match or reaches null.

const apiDefaults = {
  baseUrl: "https://api.example.com",
  timeout: 5000,
  headers() {
    return { Accept: "application/json" };
  }
};

const billingClient = Object.create(apiDefaults);
billingClient.path = "/billing";
billingClient.headers = function () {
  return {
    ...Object.getPrototypeOf(this).headers.call(this),
    Authorization: "Bearer secret"
  };
};

console.log(billingClient.baseUrl); // from prototype
console.log(billingClient.timeout); // from prototype
console.log(billingClient.headers()); // own method + parent defaults

Useful tools:

  • Object.create(proto) — create an object with a chosen prototype
  • Object.getPrototypeOf(obj) — read the link
  • Own properties shadow prototype properties (as headers does above)

In apps, you rarely hand-wire prototypes daily—but when debugging “where did this method come from?” or designing shared clients, the chain is the mental model.

Inheritance with extends and super

Class inheritance is the readable way to share behavior and specialize it.

class User {
  constructor(id, email) {
    this.id = id;
    this.email = email;
  }

  canAccess(resource) {
    return resource === "profile";
  }
}

class Admin extends User {
  constructor(id, email, permissions = []) {
    super(id, email);
    this.permissions = permissions;
  }

  canAccess(resource) {
    return this.permissions.includes(resource) || super.canAccess(resource);
  }

  invite(email) {
    return `Invite sent to ${email} by admin ${this.email}`;
  }
}

const admin = new Admin(1, "admin@shop.com", ["orders", "users", "profile"]);
console.log(admin.canAccess("orders")); // true
console.log(admin.canAccess("profile")); // true via super
console.log(admin.invite("dev@shop.com"));

super(...) in the constructor calls the parent constructor. super.method() calls the parent version after you override it. Keep inheritance shallow: one or two levels is usually enough before composition becomes clearer.

Encapsulation done right

Beginner classes often leave every property public. Advanced code hides internals so callers cannot break invariants.

Private fields (#)

class Wallet {
  #balance = 0;

  constructor(owner) {
    this.owner = owner;
  }

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

  withdraw(amount) {
    this.#assertPositive(amount);
    if (amount > this.#balance) {
      throw new Error("Insufficient funds");
    }
    this.#balance -= amount;
    return this.#balance;
  }

  get balance() {
    return this.#balance;
  }

  #assertPositive(amount) {
    if (amount <= 0) {
      throw new Error("Amount must be positive");
    }
  }
}

const wallet = new Wallet("Punam");
wallet.deposit(1000);
console.log(wallet.balance); // 1000
// wallet.#balance; // SyntaxError outside the class

Closure privacy (still useful in factories)

function createTokenVault(secret) {
  let token = null;

  return {
    setToken(value) {
      token = value;
    },
    hasToken() {
      return Boolean(token);
    },
    // never expose raw secret or token unless required
    authorize(headerSecret) {
      return headerSecret === secret && token !== null;
    }
  };
}

Prefer # private fields in modern classes. Use closures when you ship factory-style modules or need privacy without class syntax.

Abstraction and “interfaces” in JavaScript

JavaScript has no formal interface keyword. You get the same design benefit with duck typing and abstract base classes that define a contract.

class PaymentGateway {
  charge(amount, currency) {
    throw new Error("charge() must be implemented");
  }

  refund(transactionId) {
    throw new Error("refund() must be implemented");
  }
}

class StripeGateway extends PaymentGateway {
  charge(amount, currency) {
    return {
      provider: "stripe",
      status: "succeeded",
      amount,
      currency,
      id: `ch_${Date.now()}`
    };
  }

  refund(transactionId) {
    return { provider: "stripe", status: "refunded", id: transactionId };
  }
}

class PayPalGateway extends PaymentGateway {
  charge(amount, currency) {
    return {
      provider: "paypal",
      status: "succeeded",
      amount,
      currency,
      id: `pp_${Date.now()}`
    };
  }

  refund(transactionId) {
    return { provider: "paypal", status: "refunded", id: transactionId };
  }
}

function checkout(gateway, amount) {
  // Any object with charge/refund works — the "interface"
  return gateway.charge(amount, "INR");
}

console.log(checkout(new StripeGateway(), 499));
console.log(checkout(new PayPalGateway(), 499));

Call sites depend on behavior (charge/refund), not on a specific class name. That is the practical form of abstraction in JavaScript.

Polymorphism in practice

Polymorphism means different objects share a method name but implement it differently. Notification systems are a classic case.

class Notifier {
  send(message, user) {
    throw new Error("send() must be implemented");
  }
}

class EmailNotifier extends Notifier {
  send(message, user) {
    return `Email to ${user.email}: ${message}`;
  }
}

class SmsNotifier extends Notifier {
  send(message, user) {
    return `SMS to ${user.phone}: ${message}`;
  }
}

class PushNotifier extends Notifier {
  send(message, user) {
    return `Push to device ${user.deviceId}: ${message}`;
  }
}

function notifyAll(notifiers, message, user) {
  return notifiers.map((notifier) => notifier.send(message, user));
}

const user = {
  email: "aisha@example.com",
  phone: "9876543210",
  deviceId: "android-42"
};

console.log(
  notifyAll(
    [new EmailNotifier(), new SmsNotifier(), new PushNotifier()],
    "Your order shipped",
    user
  )
);

Adding a Slack notifier later does not force you to rewrite notifyAll—you add a class that implements send.

Static methods, static fields, and singletons

Static members belong to the class, not to each instance. Use them for factories, shared config, and utilities tied to that type.

class Logger {
  static #instance = null;
  static levels = ["debug", "info", "warn", "error"];

  constructor(context) {
    this.context = context;
  }

  static getInstance(context = "app") {
    if (!Logger.#instance) {
      Logger.#instance = new Logger(context);
    }
    return Logger.#instance;
  }

  static fromRequest(req) {
    return new Logger(req.headers["x-request-id"] || "anon");
  }

  info(message) {
    console.log(`[INFO][${this.context}] ${message}`);
  }
}

const logger = Logger.getInstance("checkout");
logger.info("Payment started");

const reqLogger = Logger.fromRequest({
  headers: { "x-request-id": "req_123" }
});
reqLogger.info("Handling webhook");

A singleton (getInstance) is handy for shared loggers or config caches. Do not overuse it for domain objects like orders or users—those should be normal instances.

Composition vs inheritance

Inheritance models an is-a relationship. Composition models a has-a relationship. Deep inheritance trees become brittle; composition stays flexible.

class Pricing {
  constructor(taxRate = 0.18) {
    this.taxRate = taxRate;
  }

  withTax(subtotal) {
    return subtotal + subtotal * this.taxRate;
  }
}

class Shipping {
  constructor(flatRate = 49) {
    this.flatRate = flatRate;
  }

  cost(subtotal) {
    return subtotal >= 999 ? 0 : this.flatRate;
  }
}

class Order {
  constructor({ pricing, shipping }) {
    this.items = [];
    this.pricing = pricing;
    this.shipping = shipping;
  }

  addItem(name, price, quantity = 1) {
    this.items.push({ name, price, quantity });
  }

  get subtotal() {
    return this.items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
  }

  get total() {
    const subtotal = this.subtotal;
    const shipping = this.shipping.cost(subtotal);
    return this.pricing.withTax(subtotal) + shipping;
  }
}

const order = new Order({
  pricing: new Pricing(0.18),
  shipping: new Shipping(49)
});

order.addItem("Keyboard", 1999, 1);
order.addItem("USB-C Cable", 299, 2);
console.log(order.subtotal);
console.log(order.total);

Order has pricing and shipping strategies. You can swap a festival shipping policy without subclassing Order three times. Prefer composition when behavior varies independently.

Useful OOP design patterns in JavaScript

Factory

class Discount {
  constructor(type, value) {
    this.type = type;
    this.value = value;
  }

  apply(amount) {
    if (this.type === "percent") {
      return amount - (amount * this.value) / 100;
    }
    return Math.max(0, amount - this.value);
  }

  static create(code) {
    const catalog = {
      SAVE10: new Discount("percent", 10),
      FLAT100: new Discount("flat", 100),
      NONE: new Discount("flat", 0)
    };
    return catalog[code] || catalog.NONE;
  }
}

console.log(Discount.create("SAVE10").apply(1000)); // 900

Observer (event-style)

class EventBus {
  #listeners = new Map();

  on(event, handler) {
    const list = this.#listeners.get(event) || [];
    list.push(handler);
    this.#listeners.set(event, list);
  }

  emit(event, payload) {
    const list = this.#listeners.get(event) || [];
    list.forEach((handler) => handler(payload));
  }
}

const bus = new EventBus();
bus.on("order:paid", (order) => {
  console.log(`Send invoice for order ${order.id}`);
});
bus.emit("order:paid", { id: "ORD-1001" });

Factories centralize creation rules. Observers decouple “something happened” from “what should react.” Use them when the problem matches—not as decoration on every file.

Advanced this binding and method extraction

Extracting a method from an object is a common source of bugs in callbacks and event handlers.

class CartView {
  constructor(cart) {
    this.cart = cart;
  }

  render() {
    console.log(`Items: ${this.cart.length}`);
  }

  bindEvents(button) {
    // Lost `this` if you pass this.render directly
    button.addEventListener("click", this.render.bind(this));

    // Or use an arrow field / wrapper
    button.addEventListener("click", () => this.render());
  }
}

const view = new CartView(["Pen", "Notebook"]);
const detached = view.render;
// detached(); // TypeError: cannot read length of undefined

detached.call(view); // works
view.render.call({ cart: ["Only one"] }); // temporary `this`

call, apply, and bind set this explicitly. Arrow functions do not bind their own this; they close over the surrounding one—useful for handlers, less ideal if you need dynamic this on a prototype method.

Async OOP: classes with Promises

Modern domain objects often talk to APIs. Async methods keep that I/O inside the class boundary.

class FileUploader {
  #endpoint;

  constructor(endpoint) {
    this.#endpoint = endpoint;
    this.progress = 0;
  }

  async upload(file) {
    this.progress = 0;

    // Simulated chunked upload
    for (let i = 1; i <= 4; i++) {
      await this.#uploadChunk(file, i, 4);
      this.progress = (i / 4) * 100;
    }

    return {
      ok: true,
      name: file.name,
      progress: this.progress,
      url: `${this.#endpoint}/${encodeURIComponent(file.name)}`
    };
  }

  async #uploadChunk(file, chunk, total) {
    await new Promise((resolve) => setTimeout(resolve, 50));
    console.log(`Uploaded chunk ${chunk}/${total} of ${file.name}`);
  }
}

(async () => {
  const uploader = new FileUploader("https://cdn.example.com");
  const result = await uploader.upload({ name: "resume.pdf" });
  console.log(result);
})();

Keep state transitions obvious (progress), hide transport details in private methods, and let callers await a clear public API.

Real-world capstone: e-commerce order engine

This example combines encapsulation, polymorphism, composition, and a factory into one checkout flow.

class Product {
  constructor(id, name, price) {
    this.id = id;
    this.name = name;
    this.price = price;
  }
}

class Cart {
  #items = [];

  add(product, quantity = 1) {
    const existing = this.#items.find((item) => item.product.id === product.id);
    if (existing) {
      existing.quantity += quantity;
    } else {
      this.#items.push({ product, quantity });
    }
  }

  get items() {
    return this.#items.map((item) => ({ ...item }));
  }

  get subtotal() {
    return this.#items.reduce(
      (sum, item) => sum + item.product.price * item.quantity,
      0
    );
  }
}

class PercentDiscount {
  constructor(percent) {
    this.percent = percent;
  }

  apply(amount) {
    return amount - (amount * this.percent) / 100;
  }
}

class FlatDiscount {
  constructor(value) {
    this.value = value;
  }

  apply(amount) {
    return Math.max(0, amount - this.value);
  }
}

class PaymentProcessor {
  constructor(gateway) {
    this.gateway = gateway;
  }

  pay(amount) {
    return this.gateway.charge(amount, "INR");
  }
}

class Order {
  #cart;
  #discount;
  #processor;
  #status = "draft";

  constructor(cart, discount, processor) {
    this.#cart = cart;
    this.#discount = discount;
    this.#processor = processor;
    this.id = `ORD-${Date.now()}`;
  }

  get status() {
    return this.#status;
  }

  get payable() {
    return this.#discount.apply(this.#cart.subtotal);
  }

  checkout() {
    if (this.#cart.items.length === 0) {
      throw new Error("Cart is empty");
    }

    const payment = this.#processor.pay(this.payable);
    this.#status = "paid";

    return {
      orderId: this.id,
      status: this.#status,
      subtotal: this.#cart.subtotal,
      payable: this.payable,
      payment
    };
  }
}

// Wire it together
const cart = new Cart();
cart.add(new Product("p1", "Mechanical Keyboard", 4499));
cart.add(new Product("p2", "Desk Mat", 999), 2);

const order = new Order(
  cart,
  new PercentDiscount(10), // or new FlatDiscount(200)
  new PaymentProcessor(new StripeGateway())
);

console.log(order.checkout());

What this design buys you:

  • Encapsulation — cart lines and order status are not freely mutated from outside
  • Polymorphism — percent vs flat discounts, Stripe vs PayPal gateways
  • CompositionOrder collaborates with cart, discount, and processor instead of inheriting them
  • Testability — swap a fake gateway in tests without touching order logic

Performance and maintainability tips

  • Keep methods on the prototype/class body so instances share one function object
  • Avoid deep inheritance (more than ~2 levels); compose collaborators instead
  • Do not force classes onto every script—modules and plain functions are fine for pure utilities
  • Validate at boundaries (cart empty, negative money) so invalid state never spreads
  • Document the public API of a class; treat private fields as free to change

Conclusion

Advanced JavaScript OOP is less about memorizing keywords and more about modeling change. Prototypes explain how method lookup and class really work. Private fields and closures protect invariants. Abstract contracts and polymorphism let you plug in new gateways or notifiers safely. Composition and small patterns (factory, observer) keep large features like checkout readable.

If the beginner guide taught you to build objects, this guide taught you to design systems of objects. Revisit the order engine and try extensions: coupon stacking, inventory reservation, or an async payment webhook that updates order status through an event bus.

Advanced checklist: explain the prototype link → hide state with # → prefer composition for varying behavior → depend on method contracts, not concrete classes → ship one end-to-end domain flow (like checkout) with swappable parts.

Topics
Java script Development