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 prototypeObject.getPrototypeOf(obj)— read the link- Own properties shadow prototype properties (as
headersdoes 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
- Composition —
Ordercollaborates 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.