The first time I helped a team untangle a growing JavaScript codebase, the pain was not about syntax. It was about people. One team changed a data shape, another team’s feature broke, and nobody could tell which function depended on which data without opening five files. That is the moment I reach for object oriented programming. I am not trying to make code fancy. I want clear boundaries, predictable behavior, and a shape that can hold more teammates without collapsing. If you have ever shipped a feature and worried that a small change would break three other screens, you already understand the problem OOP is trying to solve.
I will show you how OOP looks in modern JavaScript, how classes and objects fit into day to day work, and how the core ideas of encapsulation, inheritance, polymorphism, and abstraction show up in real projects. You will see complete, runnable examples, plus when I recommend using OOP and when I actively avoid it. I will also call out common mistakes and the performance trade offs I see in production work, so you can make decisions that hold up under pressure.
Why OOP shows up when teams and code grow
When a project scales, the biggest issue is not performance. It is coordination. I have watched teams struggle with:
- A change in one file breaking behavior in another because data and functions live far apart.
- Long function signatures that grow every month as new features are added.
- Code that is hard to assign to teams because there is no clear ownership of a domain.
- Copy and paste logic because no shared structure exists for common behavior.
- Flat scripts that are not modular, so testing and refactoring become slow.
OOP addresses these by putting data and behavior into a single unit, the object. A class acts as a blueprint, and an object is a real instance created from that blueprint. In practice, this means you can give a team ownership over a set of objects, and they can change the internal implementation without breaking everyone else, so long as the public interface stays stable.
I often explain it with a simple analogy. Think about a coffee machine. You push one button and the machine handles water, temperature, and timing. You do not need to manage those details every time you want a cup. In code, OOP lets you press a button like order.place() and trust that the internal steps are managed in one place.
OOP also reduces the mental cost of making changes. When I can point to a single object and say this is the only place that owns the pricing rules, I can refactor or optimize without fearing invisible coupling. That confidence is worth more than any micro performance gain.
Objects: a small unit of data plus behavior
In JavaScript, an object is a set of key value pairs. Properties hold values, and methods are functions stored on the object. This is the smallest unit of OOP and the place I start when teaching the idea.
const invoice = {
id: ‘INV-2049‘,
totalCents: 12900,
currency: ‘USD‘,
isPaid: false,
markPaid() {
this.isPaid = true;
},
formatTotal() {
return ‘$‘ + (this.totalCents / 100).toFixed(2);
}
};
console.log(invoice.formatTotal());
invoice.markPaid();
console.log(invoice.isPaid);
This pattern already gives you benefits: a single object owns its own state and actions. In my experience, object literals are ideal for small modules, configuration objects, or one off utilities. But once you need many similar objects like many invoices, carts, or users, you need a consistent factory, and that is where classes come in.
There is also a subtle win here: co location. When the data is next to the logic that uses it, the code reads like a story. That matters more than we admit when deadlines are tight.
Classes: blueprints that keep object creation consistent
A class defines a shape: what properties exist and what behaviors are available. You create instances with new. JavaScript’s class syntax is built on prototypes, but the class syntax makes the intent clear and is easier for teams to read.
class Order {
constructor(customerName, items) {
this.customerName = customerName;
this.items = items;
this.status = ‘pending‘;
}
totalCents() {
return this.items.reduce((sum, item) => sum + item.priceCents, 0);
}
confirm() {
this.status = ‘confirmed‘;
}
}
const order = new Order(‘Nina Patel‘, [
{ name: ‘Keyboard‘, priceCents: 9900 },
{ name: ‘Mouse‘, priceCents: 3900 }
]);
console.log(order.totalCents());
order.confirm();
console.log(order.status);
I recommend classes when you need multiple instances with the same behavior, when you want to enforce a consistent shape, or when you want to add validations in a single place. This is especially important in backend services or complex UIs where many entities follow the same rules.
One detail that trips people up is this. Inside class methods, this refers to the instance. If you pass a method around, you may lose the binding. I usually avoid surprises by using instance methods that do not escape, or by binding methods in the constructor when needed.
class Timer {
constructor() {
this.count = 0;
this.tick = this.tick.bind(this);
}
tick() {
this.count += 1;
return this.count;
}
}
const timer = new Timer();
const externalTick = timer.tick;
console.log(externalTick());
In frameworks that manage callbacks, I treat binding as a defensive habit. It prevents a category of bugs that are hard to find under pressure.
Abstraction and encapsulation: show the surface, hide the engine
Abstraction means you expose only what callers need to know. Encapsulation means the internal details are protected from accidental changes. JavaScript now supports private fields with #, which helps you enforce this boundary without extra libraries.
class BankAccount {
#balanceCents;
constructor(owner, startingCents = 0) {
this.owner = owner;
this.#balanceCents = startingCents;
}
deposit(cents) {
if (cents <= 0) return false;
this.#balanceCents += cents;
return true;
}
withdraw(cents) {
if (cents <= 0) return false;
if (cents > this.#balanceCents) return false;
this.#balanceCents -= cents;
return true;
}
getBalanceDollars() {
return (this.#balanceCents / 100).toFixed(2);
}
}
const account = new BankAccount(‘Kai Ito‘, 2500);
account.deposit(1000);
console.log(account.getBalanceDollars());
The caller does not need to know how the balance is stored, only that they can deposit and read it. That is abstraction. The private field prevents accidental misuse, which is encapsulation.
If you need to support older runtimes, you can mimic private data with closures. I still prefer # fields when possible because they are clear and enforced by the language. When I look at code six months later, I can see exactly what is safe to touch.
A more practical encapsulation example: protecting invariants
A useful way to think about encapsulation is protecting invariants, the rules that must always be true. When I see bugs around pricing or inventory, it is often because any part of the app can change the price or the stock count without checks. Encapsulation lets me centralize that rule.
class InventoryItem {
#stock;
constructor(sku, name, startingStock) {
this.sku = sku;
this.name = name;
this.#stock = Math.max(0, startingStock);
}
reserve(qty) {
if (qty <= 0) return false;
if (qty > this.#stock) return false;
this.#stock -= qty;
return true;
}
release(qty) {
if (qty <= 0) return false;
this.#stock += qty;
return true;
}
get stock() {
return this.#stock;
}
}
With this structure, nobody can set stock to a negative value by mistake. The object itself enforces the rule, and every caller gets the same safe behavior.
Inheritance and polymorphism: shared behavior without duplication
Inheritance lets a class reuse behavior from another class. Polymorphism means different objects can be used through the same interface. In JavaScript, this is one of the most practical benefits of OOP because you can swap implementations without changing the caller.
class PaymentMethod {
constructor(label) {
this.label = label;
}
charge(cents) {
throw new Error(‘charge() must be implemented‘);
}
}
class CardPayment extends PaymentMethod {
charge(cents) {
return ‘Charged ‘ + this.label + ‘ card for $‘ + (cents / 100).toFixed(2);
}
}
class WalletPayment extends PaymentMethod {
charge(cents) {
return ‘Charged ‘ + this.label + ‘ wallet for $‘ + (cents / 100).toFixed(2);
}
}
const methods = [
new CardPayment(‘Visa‘),
new WalletPayment(‘BluePay‘)
];
for (const method of methods) {
console.log(method.charge(4599));
}
The caller only cares that each object has charge. You can add a new method without changing the loop. That is polymorphism in action, and it is why OOP is strong for plug in style systems, payment adapters, and UI component models.
I keep inheritance shallow. Two levels are usually enough. Beyond that, I see more confusion than benefit, and I often switch to composition instead.
Composition as a safer alternative
When inheritance starts to feel like a trap, I use composition. I build small classes that do one job, then assemble them inside a larger object. This keeps each object focused and makes reuse easier.
class TaxCalculator {
constructor(rate) {
this.rate = rate;
}
apply(subtotalCents) {
return Math.round(subtotalCents * this.rate);
}
}
class ShippingCalculator {
constructor(baseCents) {
this.baseCents = baseCents;
}
apply() {
return this.baseCents;
}
}
class Checkout {
constructor(taxCalculator, shippingCalculator) {
this.taxCalculator = taxCalculator;
this.shippingCalculator = shippingCalculator;
}
total(subtotalCents) {
const tax = this.taxCalculator.apply(subtotalCents);
const shipping = this.shippingCalculator.apply();
return subtotalCents + tax + shipping;
}
}
This approach is often easier to test and swap. It also reduces the pressure to build deep inheritance trees that nobody wants to touch.
Prototypes vs classes: traditional vs modern
JavaScript is prototype based, and classes are syntax sugar over prototypes. Both are valid, but they lead to different readability and tooling outcomes. I recommend classes for most team projects, but I also teach prototypes so you understand what is happening under the hood.
Constructor and Prototype
—
Lower for newer devs
Explicit on .prototype
Manual with closures or WeakMap
Lints and types need more setup
Low for legacy code
In performance terms, I see class and prototype approaches land within the same range for most real workloads. In hot paths, differences are usually within 0 to 10 percent and can vanish after the engine warms up. The bigger performance wins come from reducing per instance method creation and from keeping object shapes stable. That is why I always place methods on the prototype, which class syntax does by default, rather than creating new functions inside the constructor.
If you are curious how prototypes work, here is a small example that mirrors the class syntax:
function OrderLegacy(customerName, items) {
this.customerName = customerName;
this.items = items;
this.status = ‘pending‘;
}
OrderLegacy.prototype.totalCents = function () {
return this.items.reduce((sum, item) => sum + item.priceCents, 0);
};
OrderLegacy.prototype.confirm = function () {
this.status = ‘confirmed‘;
};
This is not how I would start a new project, but understanding it helps when reading older code or debugging prototype chains.
A simple mental model: objects are tiny services
When I teach OOP, I tell people to imagine an object as a tiny service. It has state, it exposes a few methods, and it does not reveal its internal wiring. This helps you design smaller, focused objects.
If you are unsure whether to create a class, ask:
- Does this thing have a clear identity or role in the domain?
- Does it own data that should not be edited directly?
- Does it have behavior that should be kept consistent across callers?
When the answer is yes, a class is a good fit. When the answer is no, a plain function or a plain object is often better.
OOP in real UI work: components and view models
Modern frontend frameworks are not strictly OOP, but the mindset still applies. I use small classes as view models or domain objects that sit behind components. This keeps the UI focused on rendering while the object handles rules and formatting.
class ProfileViewModel {
constructor(user) {
this.user = user;
}
get fullName() {
return this.user.firstName + ‘ ‘ + this.user.lastName;
}
get initials() {
return this.user.firstName[0] + this.user.lastName[0];
}
canEdit() {
return this.user.role === ‘admin‘ || this.user.role === ‘editor‘;
}
}
In UI code, I avoid heavy inheritance. I prefer composition and small classes because UI tends to change quickly, and I want flexibility without breaking entire screens.
OOP in backend services: modeling the domain
Backend services are where OOP feels most natural to me. Entities like Orders, Sessions, and Invoices have real behavior, not just data. That behavior often includes validation, state changes, and formatting.
Here is a more complete example with state transitions:
class OrderState {
static PENDING = ‘pending‘;
static CONFIRMED = ‘confirmed‘;
static SHIPPED = ‘shipped‘;
static CANCELLED = ‘cancelled‘;
}
class Order {
constructor(id, items) {
this.id = id;
this.items = items;
this.state = OrderState.PENDING;
}
totalCents() {
return this.items.reduce((sum, item) => sum + item.priceCents, 0);
}
confirm() {
if (this.state !== OrderState.PENDING) return false;
this.state = OrderState.CONFIRMED;
return true;
}
ship() {
if (this.state !== OrderState.CONFIRMED) return false;
this.state = OrderState.SHIPPED;
return true;
}
cancel() {
if (this.state === OrderState.SHIPPED) return false;
this.state = OrderState.CANCELLED;
return true;
}
}
This keeps the rules in one place and makes it hard to put an order into an impossible state. That is the kind of bug prevention I care about most.
Polymorphism in practice: adapters and plug ins
Polymorphism becomes powerful when you are integrating different services or vendors. I often build an interface like NotificationSender and then provide implementations for email, SMS, and push. The calling code only knows about send.
class NotificationSender {
send(message, recipient) {
throw new Error(‘send() must be implemented‘);
}
}
class EmailSender extends NotificationSender {
send(message, recipient) {
return ‘Email to ‘ + recipient + ‘: ‘ + message;
}
}
class SmsSender extends NotificationSender {
send(message, recipient) {
return ‘SMS to ‘ + recipient + ‘: ‘ + message;
}
}
function notifyAll(sender, recipients, message) {
return recipients.map(r => sender.send(message, r));
}
When I later add PushSender, I do not change notifyAll. That is the real value: adding capabilities without rewiring the system.
When I recommend OOP, and when I do not
I use OOP when the domain has clear entities with behavior: orders, sessions, files, payments, schedules. It is strong when you have:
- Repeated behaviors across many instances.
- A need for stable public interfaces across teams.
- Complex state that needs protection from accidental edits.
- Multiple implementations that must plug into the same flow.
I avoid OOP when the problem is data transformation or simple pipelines. For example, if you are mapping and filtering data from APIs, plain functions are often clearer. I also avoid OOP when the model is mostly immutable and the main complexity is in data flow rather than in behavior. In those cases, a functional approach with small pure functions can be easier to test and reason about.
A practical rule I follow: if you find yourself passing the same bundle of related values through many functions, you should consider an object or class. If you find yourself creating a class that only holds data and has no meaningful behavior, you should consider a plain object instead.
A quick decision checklist
I often ask these questions before creating a class:
- Will there be many instances of this thing?
- Does it have rules that must always hold true?
- Does it need to hide internal representation?
- Will different implementations be swapped in later?
- Is this object likely to grow with new behavior?
If I answer yes to three or more, I usually reach for OOP.
Common mistakes and performance notes I see in real codebases
Here are the mistakes I see most often, and how I fix them:
- Too much inheritance: Deep class trees are hard to change. I keep inheritance to one or two levels and use composition for the rest.
- Instance methods created per object: Putting functions in the constructor creates a new copy for each instance. That can add memory overhead in large object sets. Put methods on the prototype instead.
- Mutable public state everywhere: If every property is public, any part of the app can change it. Use private fields or setter methods to keep control.
- Massive constructors: If the constructor does validation, network calls, and setup, object creation becomes slow and brittle. I keep constructors small and move heavy work to separate methods.
- Objects with unstable shapes: Adding or removing properties later can reduce performance in modern engines. I add all properties in the constructor to keep shapes consistent.
Performance wise, OOP is not inherently slow. The biggest impact usually comes from how you use objects. Stable shapes, prototype methods, and avoiding excess allocations are the practical rules that keep performance tight. In UI work, I also keep objects small to reduce memory pressure, which helps keep interactions smooth in complex screens.
Edge cases that trip people up
When a bug shows up in an OOP system, it is often because of a hidden edge case. Here are a few I see:
- State transitions with missing guard checks, such as confirming an already confirmed order.
- Date and time logic baked into objects without time zone clarity.
- Silent failures when methods return false instead of throwing, leaving callers unsure what happened.
- Equality checks that compare object references instead of values.
My fix is to make object methods explicit about errors and to add unit tests that model those edges, not just the happy path.
OOP and async work in JavaScript
A lot of JavaScript is asynchronous, so it helps to model async work inside objects. I often use classes to wrap external APIs so the rest of the app can call clean methods without worrying about headers or retries.
class WeatherClient {
constructor(fetchFn, baseUrl) {
this.fetchFn = fetchFn;
this.baseUrl = baseUrl;
}
async getCurrent(city) {
const response = await this.fetchFn(this.baseUrl + ‘/current?city=‘ + encodeURIComponent(city));
if (!response.ok) {
throw new Error(‘Weather API failed‘);
}
return response.json();
}
}
This keeps async details localized. It also makes testing easier because I can inject a fake fetch function without touching global state.
Testing OOP: focus on public behavior
When I test classes, I test the public methods, not the internal fields. That keeps tests stable even if I refactor internals. It also aligns with the promise that the public interface stays stable for other teams.
A good pattern is to test behavior through realistic usage:
- Create an instance.
- Call public methods in the way real code would.
- Assert on results or exposed getters.
If I need to test a private method, I usually take it as a sign that the class is doing too much. The fix is often to extract a helper class or a pure function.
Refactoring from functions to classes without chaos
Sometimes you inherit a large file of functions that operate on raw objects. I have refactored dozens of those into classes. My safe path is:
- Write a small wrapper class that uses existing functions internally.
- Migrate call sites to use the class while keeping the functions as helpers.
- Remove the old functions once the class usage is stable.
This lets you introduce OOP without a risky rewrite. It also protects your team from a big bang change that breaks the build.
OOP and TypeScript: optional but powerful
I am not going deep into TypeScript here, but it is worth saying that TypeScript makes OOP more reliable. Types can express the public interface, private fields, and the relationships between base classes and subclasses. That reduces the chance of runtime mistakes.
Even in plain JavaScript, I try to emulate that discipline by documenting the public methods and by keeping constructor parameters explicit and well named.
A modern workflow in 2026: classes plus tooling that keeps you honest
In 2026, I pair OOP with tools that make design safer without slowing me down. TypeScript helps me express the shape of classes and catch mistakes early. Static analysis catches unbound this errors. I also use AI assisted refactors to move logic into classes when a file becomes a tangle of functions. These tools are not magic, but they reduce the friction of keeping a clean object model.
When I start a new module, I usually:
- Draft a class outline with the public methods first.
- Write small usage examples to lock down the public interface.
- Implement internals with private fields and minimal constructor logic.
- Add unit tests around the public methods, not the private ones.
The key is that the public surface stays stable, even as the internal implementation changes. That is how you prevent cross team breakage and keep velocity high.
If you are learning, you should build two versions of the same feature: one with plain functions and one with classes. Compare how you add a new requirement. In my experience, OOP starts to pay off once the feature set expands and more people touch the code.
Practical scenario: building a cart with OOP
Here is a small but realistic scenario. You need a cart that can add items, apply a discount, and produce a receipt. This is a case where OOP keeps the rules in one place.
class Cart {
#items;
#discountCents;
constructor() {
this.#items = [];
this.#discountCents = 0;
}
addItem(name, priceCents) {
if (priceCents <= 0) return false;
this.#items.push({ name, priceCents });
return true;
}
applyDiscount(cents) {
if (cents < 0) return false;
this.#discountCents = cents;
return true;
}
subtotalCents() {
return this.#items.reduce((sum, item) => sum + item.priceCents, 0);
}
totalCents() {
const subtotal = this.subtotalCents();
return Math.max(0, subtotal - this.#discountCents);
}
receipt() {
const lines = this.#items.map(item => item.name + ‘ $‘ + (item.priceCents / 100).toFixed(2));
lines.push(‘Discount $‘ + (this.#discountCents / 100).toFixed(2));
lines.push(‘Total $‘ + (this.totalCents() / 100).toFixed(2));
return lines.join(‘\n‘);
}
}
Notice how all cart rules live inside the class. The rest of the app can call cart.totalCents() and trust that discounts and subtotal are handled correctly.
Alternative approaches: functional and data first
OOP is not the only good approach. Sometimes a functional style is more direct. For example, a pipeline that validates input and maps a list of records might be cleaner as pure functions. The key is to pick the tool that fits the shape of the problem.
I often combine both. I use small functions for pure transformations, and I wrap them in classes when I need to track state or enforce invariants. This hybrid approach is practical and keeps code flexible.
Common pitfalls with this in JavaScript
Because JavaScript has flexible binding rules, this can cause pain. I watch for:
- Passing a method as a callback without binding.
- Using arrow functions for class methods and accidentally creating new functions per instance.
- Mixing object literals and class instances in the same array, leading to missing methods.
The simple fix is to be disciplined about how methods are used and to keep arrays of objects homogeneous when polymorphism is expected.
Performance considerations in production
I rarely profile OOP itself. I profile the allocations and the hot paths. The most practical performance tips I use are:
- Prefer prototype methods so each instance does not carry its own function copies.
- Keep object shapes stable by defining properties in the constructor.
- Avoid per render object churn in UI components by reusing instances when possible.
- Use composition to prevent large base classes that become performance bottlenecks.
If you follow these, OOP will not be the bottleneck. The overhead is usually small compared to network, rendering, or database work.
Closing thoughts and next steps
I use object oriented programming in JavaScript because it helps me keep projects steady as they grow. It gives me a clear way to group data with behavior, protect internal details, and plug in new variations without rewriting the calling code. When I design a class well, I can change the internals without breaking the rest of the system, and that stability is what teams need when they are shipping fast.
You should start small. Pick a real feature, an order flow, a profile editor, a task scheduler, and model one entity as a class. Keep the constructor simple, put shared methods on the prototype, and protect critical state with private fields. Then add a second implementation that follows the same interface to feel polymorphism in a real way. I recommend writing a short test that calls only the public methods, because that forces you to define a clean surface.
If you do that, you will see where OOP is a fit and where it is not. That judgment is the goal. When you know when to reach for objects and when to stay functional, you build systems that are easier to change, easier to test, and easier for your teammates to read. That is the practical win I want for you.


