
Most guides to Object-Oriented Programming (OOP) open with the four pillars and start defining terms. That order is backwards. If you don't know what problem the pillars solve, you end up memorising four words instead of understanding four tools.
So this guide starts with the problem. Then it introduces OOP as one answer to it, walks through each pillar with runnable code in six languages — JavaScript, TypeScript, Python, Java, C#, and C++ — and points out what stays the same as you move between them.
In the 1950s and 60s, programs were written in languages like Fortran (1957) and later Pascal (1970) and C (1972). A program was a set of procedures — functions — operating on data that mostly lived out in the open, where any part of the program could reach it.
For a few hundred lines, this is fine. For a few hundred thousand, four specific things go wrong.
If a variable is visible to every function, then every function is a suspect when its value turns out to be wrong. Nothing in the language says who is allowed to change it, so the answer is everyone.
Analogy: A whiteboard in a shared office. Anyone walking past can erase a number and write a new one. When Monday's figure turns out to be wrong, there's no way to know who changed it, when, or why — because the whiteboard doesn't keep records and the door doesn't have a lock.
To be fair to these languages: C has file-scope static, Pascal has nested scoping, and Modula-2 and Ada shipped real modules. The tools existed. But using them was a matter of team discipline, not something the compiler enforced. Discipline scales poorly across twenty developers and five years.
A user record would be declared in one file, and the twelve functions that operate on it scattered across five others. Nothing links them. Add a field to the record and you have to remember every function that needs updating — the compiler certainly won't remind you.
Analogy: A supermarket with no sections. Shoes sit in the vegetable basket, milk is next to the paint. Every item exists somewhere, but nothing tells you where related things belong, so finding all the dairy means walking the entire store.
Suppose you have functions that manage a list of customers, and now you need the same logic for suppliers. Without a way to write the logic once and apply it to different types, the practical answer was: copy the file, find-and-replace customer with supplier, adjust the details.
Analogy: The supermarket builds a checkout counter that only works for shoes. When it starts selling socks, it builds a second counter from scratch — same design, hardcoded for socks. Now a bug in the receipt printer has to be fixed twice, and next month someone will fix it in one counter and forget the other.
Each new feature touched code you didn't write and couldn't isolate. Changing one thing broke three others, and there was no boundary you could point at and say "nothing outside this can be affected."
Analogy: The store keeps opening new aisles but still has no map, no signs, and no directory. Every aisle added makes every item harder to find — including the ones that were already there.
This growth is superlinear, not exponential: the cost of a change rises faster than the size of the codebase, because what matters is the number of possible interactions between parts, not the number of parts.
It's tempting to say OOP was invented to fix all this. It wasn't — and the real story is more interesting.
if/while/functions instead of goto), and it worked. Structured programming solved the control-flow problem and left the data problem standing.So: OOP was invented to model simulations, and adopted decades later because it happened to be a good answer to a problem it wasn't designed for.
Worth knowing: OOP is one answer, not the answer. Modules (Ada, Modula-2, Go packages, Rust modules) address the same problems without objects, and functional programming attacks them from a different angle — by making data immutable, so "who changed this?" stops being a question at all. Modern codebases usually mix all three.
Object-Oriented Programming organises software around objects — bundles of data together with the behaviour that operates on that data — rather than around free-floating functions.
The two words you'll see constantly:
Analogy: A class is a cookie cutter; an object is a cookie. One cutter, unlimited cookies, each with its own sprinkles. The cutter defines the shape. You eat the cookie, not the cutter.
Four principles fall out of this idea — the Four Pillars:
Here's the part most guides skip. Each pillar maps directly onto one of the four problems above:
| The problem | The pillar that answers it | How |
|---|---|---|
| Shared data with no owner | Encapsulation | Data lives inside an object; only that object's own methods can touch it |
| Data and behaviour drift apart | The class itself, enforced by encapsulation | The language requires them to live in one unit |
| Reuse meant copying | Inheritance (and composition) | Write shared logic once, extend it for specific cases |
| Growth outpaced understanding | Abstraction + Polymorphism | Work against simple interfaces, so new types plug in without changing existing code |
Read the pillars as answers rather than as vocabulary, and the rest of this guide will make a lot more sense.
Encapsulation means bundling data and the methods that operate on it into one unit, and controlling who can reach that data from outside.
Analogy: An ATM. The cash is inside the machine, and you cannot reach in and take it. You interact through a small set of allowed operations — check balance, withdraw, deposit — and each one enforces rules before it does anything. The machine decides what's permitted; you don't.
Almost every OOP language gives you three levels of access. The names are nearly identical everywhere, which makes this one of the easiest things to carry between languages.
Analogy: A house.
- public — the front porch. Anyone walking by can see it and use it.
- protected — the family rooms. You and your children can go in; the neighbours can't.
- private — your diary in a locked drawer. Only you. Not even your children.
The one that trips people up is protected, so state it plainly: protected means the class itself and any class that inherits from it — but nothing outside that family line.
Here's all three in one class. name is open to everyone, department is visible to subclasses, salaryBand is sealed inside:
class Employee {
name; // public — no keyword needed, this is the default
_department; // "protected" by convention only — nothing enforces it
#salaryBand; // private — genuinely enforced by the language
constructor(name, department, salaryBand) {
this.name = name;
this._department = department;
this.#salaryBand = salaryBand;
}
describe() {
// Inside the class, everything is reachable.
return `${this.name} (${this._department}, band ${this.#salaryBand})`;
}
}
class Manager extends Employee {
report() {
// Works: the underscore is a naming convention, not a barrier.
return `Managing ${this._department}`;
// return this.#salaryBand; // SyntaxError — # fields are NOT visible to subclasses
}
}
const e = new Employee("Ada", "Engineering", 4);
console.log(e.name); // "Ada" — public, fine
console.log(e._department); // "Engineering" — reachable! convention isn't protection
// console.log(e.#salaryBand); // SyntaxError — truly private
Making a field private and then adding a getter that returns it and a setter that assigns it achieves nothing — you've written six lines to rebuild a public field. The point of a setter is the rule inside it.
Analogy: A receptionist. The value of one isn't that they carry messages to the office — it's that they check whether you have an appointment first.
This BankAccount refuses negative deposits and overdrafts. There is no path to the balance that skips those checks:
class BankAccount {
#balance; // Private field (ES2022+)
constructor(initialBalance) {
this.#balance = initialBalance > 0 ? initialBalance : 0;
}
deposit(amount) {
if (amount <= 0) {
throw new Error("Deposit amount must be positive.");
}
this.#balance += amount;
}
withdraw(amount) {
if (amount <= 0) {
throw new Error("Withdrawal amount must be positive.");
}
if (amount > this.#balance) {
throw new Error("Insufficient funds.");
}
this.#balance -= amount;
}
getBalance() {
return this.#balance;
}
}
const account = new BankAccount(100);
account.deposit(50);
// account.#balance = 1000; // SyntaxError: Private field
console.log(account.getBalance()); // 150
Read those six tabs side by side and the shape is identical: a hidden field, a constructor that sanitises its input, methods that validate before mutating, and one read-only way out. The differences are spelling and how hard the language pushes back.
| Language | private | protected | public | Actually enforced? |
|---|---|---|---|---|
| JavaScript | #field | no equivalent | default | Yes for #. _name is convention only |
| TypeScript | private | protected | public (default) | Compile time only — erased at runtime |
| Python | __field (name-mangled) | _field (convention) | default | No — both are advisory |
| Java | private | protected (+ same package) | public | Yes, compile and run time |
| C# | private (default) | protected | public | Yes, compile and run time |
| C++ | private: (class default) | protected: | public: | Yes, compile time |
Three things to take away:
private means the same thing in all six. "Only code inside this class." No exceptions to learn.protected is the same in five of six. Java, C#, C++, and TypeScript all mean "this class and its subclasses." Python approximates it with a single leading underscore. JavaScript has no equivalent at all — a #private field is invisible even to subclasses, so if a child class needs it, your only options are a _field by convention or a public accessor.# fields are real walls — the code will not compile or run. TypeScript's are checked by the compiler and then erased. Python's are a sign on the door. In practice this matters less than you'd expect: teams respect the convention, and the discipline is the point either way.Two extras that don't exist everywhere: Java's package-private (write no modifier at all) and C#'s internal (visible within the same assembly) are both "visible to my neighbours but not to strangers" — a fourth level sitting between protected and public, with no equivalent in the other four languages.
Abstraction means exposing what something does while hiding how it does it.
Analogy: Driving a car. Steering wheel, pedals, gear stick. You've never needed to know how the fuel injection timing works, and the car is usable because you don't. Swap the petrol engine for an electric motor and the interface is unchanged — you still press the right pedal to go.
Encapsulation and abstraction get confused constantly, so: encapsulation hides data, abstraction hides complexity. Encapsulation is the locked drawer. Abstraction is the fact that you were only ever shown three buttons in the first place.
Two tools do this work:
Analogy: An interface is a job description: "must be able to drive, lift 20kg, work weekends." It says nothing about who you are. An abstract class is a half-trained employee: already knows the company procedures, still needs teaching the specific role.
Rule of thumb: abstract class when children share code; interface when they only share a contract. A class can implement many interfaces but usually inherits from only one class.
Support varies more here than anywhere else, so each tab notes how that language handles it:
// JavaScript has no abstract classes and no interfaces.
// You simulate them: block direct construction, and make
// unimplemented methods throw.
class Shape {
constructor() {
if (new.target === Shape) {
throw new Error("Cannot instantiate abstract class directly.");
}
}
calculateArea() {
throw new Error("Method 'calculateArea()' must be implemented.");
}
draw() {
throw new Error("Method 'draw()' must be implemented.");
}
describe() {
console.log(`This shape has an area of ${this.calculateArea()}`);
}
}
class Circle extends Shape {
constructor(radius) {
super();
this.radius = radius;
}
calculateArea() {
return Math.PI * this.radius ** 2;
}
draw() {
console.log(`Drawing a circle with radius ${this.radius}`);
}
}
const circle = new Circle(5);
circle.draw(); // Drawing a circle with radius 5
circle.describe(); // This shape has an area of 78.539...
// new Shape(); // Error: Cannot instantiate abstract class directly.
// The trade-off: these failures happen at RUNTIME, when the missing
// method is actually called. Typed languages catch them at compile time.
Every version does the same two things: declare a method with no body, and stop anyone from instantiating the incomplete class. Only the syntax for "no body" changes — abstract in TypeScript, Java, and C#, @abstractmethod in Python, = 0 in C++, a thrown error in JavaScript.
| Language | Abstract class | Interface | Caught when? |
|---|---|---|---|
| JavaScript | Convention: throw in constructor and stubs | None — duck typing | Runtime |
| TypeScript | abstract class | interface | Compile time |
| Python | ABC + @abstractmethod | ABC with all methods abstract | Runtime (at instantiation) |
| Java | abstract class | interface | Compile time |
| C# | abstract class | interface (IName by convention) | Compile time |
| C++ | Any class with a = 0 method | Class with only = 0 methods | Compile time |
Two patterns worth noticing:
interface keyword plus a single inheritance slot. That shapes how code is written in them: since you can extend only one class but implement many interfaces, interfaces become the main tool for sharing behaviour across unrelated types.Inheritance lets one class take on the fields and methods of another, then add or change what it needs. It expresses an "is-a" relationship.
Analogy: A job hierarchy. Every Manager is an Employee — they clock in, they take leave, they get paid, all inherited. A Manager also approves budgets, which ordinary employees can't. And a Manager might take leave through a different procedure, needing sign-off from a director. That last part — same task, different procedure — is overriding.
The test to apply before reaching for inheritance: can you say "is-a" and mean it? A Dog is-a Animal, yes. A Car is-a Engine, no — a car has an engine. Get this backwards and you'll fight your own design for months (see Pitfall 2).
class Animal {
constructor(name) {
this.name = name;
}
eat() {
console.log(`${this.name} is eating.`);
}
sleep() {
console.log(`${this.name} is sleeping.`);
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // run the parent's constructor first — mandatory
this.breed = breed;
}
bark() { // new behaviour, parent knows nothing about it
console.log(`${this.name} says: Woof!`);
}
eat() { // override: same name, replaces the parent version
console.log(`${this.name} the ${this.breed} is eating dog food.`);
}
}
const dog = new Dog("Buddy", "Golden Retriever");
dog.eat(); // Buddy the Golden Retriever is eating dog food. (overridden)
dog.sleep(); // Buddy is sleeping. (inherited)
dog.bark(); // Buddy says: Woof! (new)
All six do three identical things: name a parent, call the parent's constructor before using the object, and redefine a method to change its behaviour. Only the punctuation differs.
| Language | Inherit from | Call parent constructor | Refer to parent | Override marker |
|---|---|---|---|---|
| JavaScript | extends | super(...) | super.method() | none |
| TypeScript | extends | super(...) | super.method() | override (optional) |
| Python | class Dog(Animal) | super().__init__(...) | super().method() | none |
| Java | extends | super(...) | super.method() | @Override (optional) |
| C# | : Animal | : base(...) | base.Method() | override (required) |
| C++ | : public Animal | initializer list | Animal::method() | override (optional) |
The differences that actually bite:
virtual before a child can override it. In JavaScript, TypeScript, Python, and Java, every method is overridable unless you explicitly seal it (final in Java, sealed in C#). Coming from Java to C#, forgetting virtual is the single most common cause of "why isn't my override running?"super vs base vs Parent:: — three spellings, one idea: "run the parent's version of this."override is mandatory only in C#. Everywhere else it's optional — but use it anyway. Without it, a typo in the method name silently creates a new method instead of an override, and the parent's version keeps running.Polymorphism means "many forms." One instruction, sent to different objects, produces the behaviour appropriate to each.
Analogy: The Play button on a universal remote. Same button, but the TV starts a channel, the speaker starts a track, the console resumes a game. You don't check what device you're pointing at first — you press Play, and each device knows what that means for itself.
This is what makes code extensible. Write a function that accepts Animal and it works with every animal that will ever exist — including ones written years later by someone else — without a single edit.
These two get mixed up constantly, and the similar names don't help. They are unrelated mechanisms.
| Overriding | Overloading | |
|---|---|---|
| What changes | The body | The parameters |
| Where | Child class replaces a parent's method | Same class, several methods sharing a name |
| Signature | Must be identical to the parent's | Must be different from each other |
| Decided | At runtime, by the object's real type | At compile time, by the arguments passed |
| Needs inheritance? | Yes | No |
Analogy for overriding: Your company has a "handle complaint" procedure. Support follows the standard one; Legal has its own version of the same task. Same request, different department, different procedure — and which one runs depends on who you handed it to.
Analogy for overloading: A coffee shop's order counter. Say
order("latte"), ororder("latte", "large"), ororder("latte", "large", "oat milk"). One name, several accepted forms, and the barista knows which you meant from what you said.
The runtime/compile-time split is the distinction that matters in practice. Overloading is resolved by the compiler inspecting your arguments — decided before the program ever runs. Overriding can't be resolved early, because the compiler only knows you have an Animal; whether it's a Dog or a Cat is a fact that exists only at runtime. That late decision is called dynamic dispatch, and it's the machinery that makes polymorphism work.
Here's overriding — one makeSound() call producing three different results:
class Animal {
constructor(name) {
this.name = name;
}
makeSound() {
console.log(`${this.name} makes a sound.`);
}
}
class Dog extends Animal {
makeSound() {
console.log(`${this.name} barks: Woof!`);
}
}
class Cat extends Animal {
makeSound() {
console.log(`${this.name} meows: Meow!`);
}
}
// One loop, three behaviours. No type checks anywhere.
const animals = [new Dog("Buddy"), new Cat("Whiskers"), new Animal("Generic")];
animals.forEach(animal => animal.makeSound());
// Buddy barks: Woof!
// Whiskers meows: Meow!
// Generic makes a sound.
And here's overloading — one name, several parameter lists. Note that half these languages don't support it at all and have to fake it:
// JavaScript has NO overloading. Declaring add() twice simply replaces
// the first with the second — no error, no warning.
// The idiomatic workaround is default and rest parameters.
class Calculator {
add(a, b = 0, c = 0) {
return a + b + c;
}
// Or inspect the arguments yourself:
describe(value) {
if (typeof value === "string") return `text: ${value}`;
if (Array.isArray(value)) return `list of ${value.length}`;
return `number: ${value}`;
}
}
const calc = new Calculator();
console.log(calc.add(2, 3)); // 5
console.log(calc.add(2, 3, 4)); // 9
console.log(calc.describe("hi")); // text: hi
Overriding works the same way in all six. Redefine the method in the child, call it through a parent-typed reference, and the child's version runs. Two caveats:
virtual on the parent method first. Forget it and you get the parent's behaviour with no error message — the hardest kind of bug to spot.Animal and the derived part is sliced off.Overloading splits the six into two camps, and this is the biggest genuine divide in the whole article:
| Language | Real overloading? | What you use instead |
|---|---|---|
| JavaScript | No | Default params, rest params, manual type checks |
| TypeScript | Signatures only | Overload signatures + one implementation |
| Python | No | Default params, @singledispatchmethod |
| Java | Yes | — |
| C# | Yes | — (also optional parameters) |
| C++ | Yes | — (plus operator overloading) |
The pattern isn't arbitrary: overloading requires the compiler to know each argument's type at compile time in order to pick a method. Dynamically typed languages don't have that information, which is why JavaScript and Python can't offer it. TypeScript sits in between — its compiler knows the types, but it compiles down to JavaScript, so the checking is real and the dispatch is not.
One rule holds everywhere overloading exists: return type alone never distinguishes an overload. Two methods differing only in what they return is an error in Java, C#, and C++ alike.
Terms you'll meet constantly, defined as briefly as they can honestly be defined.
Class — the blueprint. A cookie cutter.
Object / instance — one thing built from that blueprint. A cookie.
Instantiation — the act of making an object from a class. Pressing the cutter into dough.
Field / attribute / property — data an object holds; the nouns. A cookie's flavour.
Method — a function belonging to a class; the verbs. What the object can do.
Constructor — code that runs the moment an object is created, to set it up. Filling in the form when you open a bank account.
Destructor — code that runs when an object is destroyed; matters mainly in C++, since Java, C#, Python, and JavaScript clean up automatically via garbage collection. Handing back the keys when you move out.
this / self — the object's word for "me," used inside its own methods.
Static member — belongs to the class itself rather than any one object, so there's exactly one copy shared by all. The shop's opening hours: one value for the whole shop, not one per customer.
Static class — a class you never create objects from, used purely as a container for related functions. A toolbox bolted to the wall — you use the tools where they are, you don't take the box home. (static class in C#; in Java, a final class with a private constructor; in Python, just a module.)
Instance variable vs class variable — one value per object, versus one value shared by every object of the class.
Getter / setter — controlled methods for reading and writing a private field, so rules can be enforced on the way in.
Method signature — a method's name plus its parameter types; its fingerprint, and the thing overloading varies and overriding must match.
Virtual method — a method a child class is permitted to override. Default in Java, Python, and JavaScript; must be requested with virtual in C# and C++.
Dynamic dispatch / v-table — the runtime lookup deciding which version of an overridden method actually runs.
Upcasting — treating a Dog as an Animal; always safe, since every Dog is an Animal.
Downcasting — treating an Animal as a Dog; needs a check first, because it might be a Cat.
Composition — building an object out of other objects: a "has-a" relationship. A car has an engine. Usually the better choice over inheritance.
Multiple inheritance — having more than one parent class. Supported in C++ and Python; deliberately banned in Java and C#.
Diamond problem — under multiple inheritance, when two parents provide the same method, which one wins? Python answers with the MRO (Method Resolution Order), C++ with virtual inheritance, Java and C# by forbidding the situation.
Mixin — a small class holding one reusable capability, meant to be blended into others rather than used alone. A bolt-on accessory.
Duck typing — if it has the method you need, it's the right type; no shared base class required. "If it walks like a duck and quacks like a duck, it's a duck." Central to Python and JavaScript.
Immutable object — one whose state can never change after construction, sidestepping the shared-data problem from Section 1 entirely. Written in pen, not pencil.
Namespace / package / module — a surname for your classes, so two User classes from different libraries can coexist.
Singleton — a class deliberately restricted to exactly one instance. The one and only master key. Easy to overuse; it's global state wearing a costume.
Interface vs abstract class — a contract with no code, versus a partial implementation with gaps to fill.
SOLID — five widely cited design principles, of which the most immediately useful is the Open/Closed Principle: open for extension, closed for modification.
Take a payment system handling cards, PayPal, and crypto:
PaymentMethod interface declaring processPayment(amount). That's all a caller ever needs to know.CreditCard, PayPal, and Crypto each implement it, sharing whatever base logic makes sense.Checkout class holds a PaymentMethod and calls processPayment(), never knowing or asking which kind it has.Now add Apple Pay. You write one new class. Checkout is not modified, not recompiled, not retested. Nothing that already worked can break, because nothing that already worked was touched.
That's the Open/Closed Principle — open for extension, closed for modification — and it's the clearest demonstration of why the pillars are worth learning. Compare it against Section 1's fourth problem, where every new feature made every existing feature riskier.
One class that knows everything and does everything. Symptom: a 2,000-line file every feature has to touch.
Fix: Split by responsibility. If you can't describe a class in one sentence without saying "and," it's doing too much.
Vehicle → Car → Sedan → LuxurySedan → BMW7Series. Every level tightens the coupling, and a change to Vehicle can break things five levels down — the fragile base class problem.
Fix: Prefer composition. A Car has an Engine, a Transmission, and an InfotainmentSystem — it isn't a subclass of any of them. As a rough limit, be suspicious past two or three levels.
Classes that are nothing but private fields with a getter and setter for each. This is encapsulation as theatre: if every field has a public setter, the data is public with extra steps.
Fix: Move behaviour into the object. account.transferTo(other, 50) enforces rules; account.setBalance(x) enforces nothing.
Writing an interface for something with exactly one implementation, "in case we need another." Usually you never do, and now every reader has an extra layer to trace through.
Fix: Start concrete. Abstract on the second real implementation, when you can see what actually varies.
Writing Java in Python — get_name()/set_name() everywhere instead of @property, an interface hierarchy where duck typing would do.
Fix: Learn each language's own OOP dialect. Python favours duck typing and properties; JavaScript favours composition and closures; C++ favours RAII and value semantics; Java and C# lean hardest on explicit interfaces.
| Pillar | The question it answers | The mechanism |
|---|---|---|
| Encapsulation | Who is allowed to touch this data? | Access modifiers, getters and setters |
| Abstraction | What can the caller safely ignore? | Abstract classes, interfaces |
| Inheritance | What do these types share? | extends, :, class Child(Parent) |
| Polymorphism | How can one call serve many types? | Overriding, virtual methods, dynamic dispatch |
And the cross-language picture in one table:
| Concept | JavaScript | TypeScript | Python | Java | C# | C++ |
|---|---|---|---|---|---|---|
| private | #field | private | __field | private | private | private: |
| protected | none | protected | _field | protected | protected | protected: |
| Enforced | yes (#) | compile only | no | yes | yes | yes |
| Abstract | simulated | abstract | @abstractmethod | abstract | abstract | = 0 |
| Interface | none | interface | all-abstract ABC | interface | interface | all-pure-virtual class |
| Override needs opt-in | no | no | no | no | virtual | virtual |
| Overloading | no | signatures only | no | yes | yes | yes |
| Multiple inheritance | no | no | yes | no | no | yes |
The four pillars aren't four separate rules to memorise. They're one idea seen from four angles: put related things together, and control what the outside world can see.
Encapsulation protects the data so abstraction can offer a clean surface. Inheritance shares that surface across a family of types. Polymorphism lets those types be used interchangeably. Remove any one and the others weaken.
The real skill isn't reciting the definitions — it's recognising which problem you have. Data being modified from somewhere you can't identify? An encapsulation problem. A function full of if (type === ...) branches? Polymorphism waiting to happen. Copy-pasted logic diverging across files? Inheritance or composition. Learn to spot the symptom and the right pillar is usually obvious.
A final note on perspective. OOP is a tool, not a worldview. The best modern code borrows freely: objects where state genuinely belongs together, plain functions where it doesn't, immutability wherever it's affordable. Learn the pillars well enough to know when not to reach for them — that's the point at which you've actually understood them.
Want to advertise your product here? Contact Us
No comments yet — be the first to share your thoughts.