OOP principles
In short: Object-oriented programming rests on four principles: encapsulation, inheritance, polymorphism and abstraction. They guard the data, write what is shared once and replace the if-chains on the kind of object.
Problem
Payroll without principles
At the end of the month the company pays its people. The program was written in a hurry: one class with public fields, the role is text, and the pay is worked out in two places. The monthlyPay function makes the payslip, and transfer sends the money to the bank.
class Employee { constructor(name, role, salary, bonus) { this.name = name; this.role = role; this.salary = salary; this.bonus = bonus; }}function monthlyPay(e) { if (e.role === "manager") { return e.salary + e.bonus; } else if (e.role === "intern") { return Math.trunc(e.salary / 2); } return e.salary;}
function transfer(staff) { let total = 0; for (const e of staff) { let amount = e.salary; if (e.role === "manager") { amount += e.bonus; } console.log(`Transfer: ${e.name} €${amount}`); total += amount; } console.log(`Total: €${total}`);}Today accounting enters a raise for Ivan. But the form says -10 instead of 10:
const staff = [ new Employee("Ivan", "employee", 2000, 0), new Employee("Maria", "manager", 3000, 500), new Employee("Ana", "intern", 1600, 0),];const percent = -10;staff[0].salary += Math.trunc(staff[0].salary * percent / 100);for (const e of staff) { console.log(`Payslip: ${e.name} €${monthlyPay(e)}`);}transfer(staff);Payslip: Ivan €1800Payslip: Maria €3500Payslip: Ana €800Transfer: Ivan €1800Transfer: Maria €3500Transfer: Ana €1600Total: €6900The program does not stop and does not complain. But the output has three cracks:
- A bad write. Ivan's salary dropped to €1800. The field is open to every line in the program, and nobody asks whether -10% is a raise.
- Two copies of one rule. Ana's payslip says €800, and the bank sends her €1600. The rule "an intern gets half" is in the payslip, but someone forgot it in the transfer. The copy drifted away from the original, and nobody noticed.
- A new kind, many fixes. Tomorrow a freelancer joins. You need a new
else ifin the payslip, in the transfer, in tax, in leave, everywhere someone asks about the role. And every employee already carries abonusfield, though only the manager has a bonus.
Object-oriented programming is the answer to exactly these three things.
The idea
The four principles
Object-oriented programming (OOP) organizes a program around objects: data together with the methods that touch it. It rests on four principles. You have already worked with each of them in the modules "Classes and objects" and "Inheritance and polymorphism":
| Principle | What it says | Which crack it fixes |
|---|---|---|
| Encapsulation | The object guards its own data. From outside you reach it only through methods that check. | The bad write: -10% is refused. |
| Inheritance | What several classes share is written once, in a parent. | The bonus lives only in Manager, and what is shared (name, salary, the check in the raise) is written once in Employee. |
| Polymorphism | One call, and every object answers in its own way. | The if on the role: every class works out its own pay, with no second copy. |
| Abstraction | Code depends on what an object can do, not on how it does it. | A new kind of payment joins without touching the transfer. |
In short: encapsulation guards, inheritance shares, polymorphism chooses, abstraction promises.
From here on, each principle fixes one crack in the payroll program. At the end you will see them together, in the same program.
The idea
Encapsulation: the object guards its data
The salary belongs to the employee. Let only the employee change it. The field becomes private, with #, and the raise is a method that checks:
class Employee { #name; #salary;
constructor(name, salary) { this.#name = name; this.#salary = salary; }
get name() { return this.#name; }
get salary() { return this.#salary; }
raise(percent) { if (percent > 0) { this.#salary += Math.trunc(this.#salary * percent / 100); } }}const ivan = new Employee("Ivan", 2000);ivan.raise(-10);console.log(ivan.salary);ivan.raise(10);console.log(ivan.salary);20002200-10% is refused, 10% goes through. The check is in one place, the class, and any code that wants to change the salary goes through it. This is encapsulation: the data and the rules for it sit together in the object, and from outside you see only what the class chooses to show.
With # the field is truly private. A program that has this line outside the class
ivan.#salary = -2000;does not start at all:
SyntaxError: Private field '#salary' must be declared in an enclosing classFrom outside the salary is read through the salary getter, which has no setter, and changed only with raise().
When it hurts: getters and setters for everything. If every field has a getter and a setter that writes whatever you give it, the field is still open, only with more lines. Encapsulation is not "a hidden field plus get and set". It is: the object decides what may change and how. That is why Employee has no method that writes the salary, only a raise with a check. Start with everything hidden and open only what you must.
More precisely, if you are curious
#guards against child classes too:Managercannot see#salaryeither. That is why it reads the salary through thesalarygetter.- Before
#existed (until 2022), JavaScript used the_salaryagreement, as in Python. You will see it in older code.
Try it
What hides the salary?
class Employee {
#name;
;#salary: with # the field is visible only to the code in Employee. A program that mentions it outside the class does not even start.The idea
Inheritance: what is shared, once
A manager is an employee with a bonus. Instead of a copy of Employee with one more field, Manager inherits everything and adds only the bonus:
class Manager extends Employee { #bonus;
constructor(name, salary, bonus) { super(name, salary); this.#bonus = bonus; }
get bonus() { return this.#bonus; }}const maria = new Manager("Maria", 3000, 500);maria.raise(-10);maria.raise(10);console.log(`${maria.name}: ${maria.salary} + ${maria.bonus}`);Maria: 3300 + 500The manager has no raise check of its own. It takes the one from Employee: -10% is refused, and 10% lifts 3000 to 3300. If the rule for raises changes tomorrow, you change it in one place and it holds for every child class. This is inheritance: what is shared sits in the parent, and the child writes only its own part.
super(name, salary) hands the name and the salary to the parent's constructor. #salary is hidden from the child too, so Manager reads it through the salary getter. After extends there is exactly one class.
The test for whether inheritance makes sense is the sentence "X is a Y". "A manager is an employee" is true.
When it hurts: long chains and inheriting for ready-made code. Employee → Manager → SeniorManager → RegionalSeniorManager: to understand what the bottom class does, you read four classes, and a change at the top moves everything below it. Another common mistake is to inherit a class only to get a ready-made method: an invoice that extends Employee so that it has a name. "An invoice is an employee" is not true, and it would get a salary and a raise. What to do instead is in the card "What people mix up" below.
The idea
Polymorphism: each class answers for itself
Now the crack with the copies. Instead of an if on the role, every class works out its own monthly pay. The parent has a general version, and the child classes override it:
class Employee { // ... #name, #salary, salary and raise() from before
monthlyPay() { return this.#salary; }}
class Manager extends Employee { // ... #bonus and the constructor
monthlyPay() { return this.salary + this.#bonus; }}
class Intern extends Employee { monthlyPay() { return Math.trunc(this.salary / 2); }}Both the payslip and the transfer call the same method:
function transfer(staff) { let total = 0; for (const e of staff) { console.log(`Transfer: ${e.name} €${e.monthlyPay()}`); total += e.monthlyPay(); } console.log(`Total: €${total}`);}const staff = [ new Employee("Ivan", 2000), new Manager("Maria", 3000, 500), new Intern("Ana", 1600),];for (const e of staff) { console.log(`Payslip: ${e.name} €${e.monthlyPay()}`);}transfer(staff);Payslip: Ivan €2000Payslip: Maria €3500Payslip: Ana €800Transfer: Ivan €2000Transfer: Maria €3500Transfer: Ana €800Total: €6300Ana gets €800 both on the payslip and in the transfer. The intern rule lives only in Intern, and there is no second copy to drift away. A new kind of employee is a new class with its own method, and the loop and the transfer stay untouched. This is polymorphism: one call, e.monthlyPay(), and every object answers in its own way.
JavaScript looks the method up by name: first in the object's class, then in its parent. Nothing is checked in advance. Any object that has a method with that name works for the loop, whatever its class. This is called duck typing: "if it walks like a duck and quacks like a duck, it is a duck".
When it hurts. Two cases that will never become three do not need a class hierarchy: one if is shorter and clearer. Also: a child class that overrides the method but breaks the parent's promise (a pay that comes out negative, or a method that stops the program) breaks every loop that trusts the parent.
Try it
How much will the bank send?
The classes are from the cards above, together with the payment contract from the next card: amountDue() is in Employee and returns this.monthlyPay().
const e = new Intern("Ana", 1600);e.raise(10);console.log(e.amountDue());The raise is from Employee: 1600 + 10% = 1760. amountDue() is also from Employee, but it calls monthlyPay() on the object, and the object is an intern. The Intern version runs: 1760 / 2 = 880. Three principles in three lines.
The idea
Abstraction: what, not how
Accounting wants the suppliers' invoices in the same transfer. An invoice is not an employee. But the transfer does not care what kind of thing each one is. It needs two things: to whom, and how much. That is the contract:
// The "can be paid" contract. Every payable object has:// amountDue(): how much is owed// payee(): to whom
class Employee { // ... everything from before
amountDue() { return this.monthlyPay(); }
payee() { return this.#name; }}
function transfer(payments) { let total = 0; for (const p of payments) { console.log(`Transfer: ${p.payee()} €${p.amountDue()}`); total += p.amountDue(); } console.log(`Total: €${total}`);}class Invoice { #supplier; #amount;
constructor(supplier, amount) { this.#supplier = supplier; this.#amount = amount; }
amountDue() { return this.#amount; }
payee() { return this.#supplier; }}const payments = [ new Employee("Ivan", 2000), new Manager("Maria", 3000, 500), new Intern("Ana", 1600), new Invoice("Power company", 340),];transfer(payments);Transfer: Ivan €2000Transfer: Maria €3500Transfer: Ana €800Transfer: Power company €340Total: €6640transfer knows nothing about salaries, bonuses and invoices. It knows only the contract. This is abstraction: you show what an object can do and hide how it does it. The transfer depends on the abstraction, so anything that fulfills it joins without a change to the transfer.
JavaScript has neither interface nor abstract. The contract is an agreement about method names, written in a comment. Nobody checks it: an object without amountDue() enters the array without trouble, and the program stops only when the loop reaches it, with TypeError: p.amountDue is not a function. That is the price of duck typing.
A contract is one form of abstraction. The other is a class whose methods stop the program if the child does not write them: Shape promises an area, and each shape says how it is worked out ("Abstract classes"). When to use which is in the lesson "Interfaces".
When it hurts: abstraction too early. A contract with a single implementation, "just in case", is one more name, one more file and one more jump while you read. Abstraction pays off when there are at least two implementations, or when you need to swap the one you have, for example in a test. Payable appeared when the invoice came, not before.
See it
The four together
The scene walks through the program from the start: each principle fixes its own crack, and the numbers below come from the same program.
const staff = [ new Employee("Ivan", 2000), new Manager("Maria", 3000, 500), new Intern("Ana", 1600),];staff[0].raise(-10);for (const e of staff) { console.log(`Payslip: ${e.name} €${e.monthlyPay()}`);}const payments = [staff[0], staff[1], staff[2], new Invoice("Power company", 340)];transfer(payments);function monthlyPay(e) { if (e.role === "manager") { return e.salary + e.bonus; } else if (e.role === "intern") { return Math.trunc(e.salary / 2); } return e.salary;}
function transfer(staff) { let total = 0; for (const e of staff) { let amount = e.salary; if (e.role === "manager") { amount += e.bonus; } console.log(`Transfer: ${e.name} €${amount}`); total += amount; } console.log(`Total: €${total}`);}
const percent = -10;staff[0].salary += Math.trunc(staff[0].salary * percent / 100);class Employee { #name; #salary;
constructor(name, salary) { this.#name = name; this.#salary = salary; }
get name() { return this.#name; }
get salary() { return this.#salary; }
raise(percent) { if (percent > 0) { this.#salary += Math.trunc(this.#salary * percent / 100); } }}class Manager extends Employee { #bonus;
constructor(name, salary, bonus) { super(name, salary); this.#bonus = bonus; }
get bonus() { return this.#bonus; }}class Employee { // ... #name, #salary, salary and raise() from before
monthlyPay() { return this.#salary; }}
class Manager extends Employee { // ... #bonus and the constructor
monthlyPay() { return this.salary + this.#bonus; }}
class Intern extends Employee { monthlyPay() { return Math.trunc(this.salary / 2); }}// The "can be paid" contract. Every payable object has:// amountDue(): how much is owed// payee(): to whom
class Employee { // ... everything from before
amountDue() { return this.monthlyPay(); }
payee() { return this.#name; }}
function transfer(payments) { let total = 0; for (const p of payments) { console.log(`Transfer: ${p.payee()} €${p.amountDue()}`); total += p.amountDue(); } console.log(`Total: €${total}`);}const staff = [ new Employee("Ivan", 2000), new Manager("Maria", 3000, 500), new Intern("Ana", 1600),];staff[0].raise(-10);for (const e of staff) { console.log(`Payslip: ${e.name} €${e.monthlyPay()}`);}const payments = [staff[0], staff[1], staff[2], new Invoice("Power company", 340)];transfer(payments);Step by step, in words
- Before: the role is text, the pay rule is copied into two chains of if, and anyone can write the salary. Ivan gets €1800, and Ana's payslip and transfer disagree.
- Encapsulation: the salary is hidden, and raise() refuses -10%. Ivan stays at €2000.
- Inheritance: Manager takes the name, the salary and the raise check from Employee and adds only the bonus. The objects now have classes, not a role in text.
- Polymorphism: every class works out its own monthlyPay(). The transfer calls the same method as the payslip, so Ana gets €800 in both.
- Abstraction: the transfer only knows the contract: amountDue() and payee(). The power company invoice joins without being an employee.
- All four together: the same -10% is refused, every payslip matches its transfer, and the total is €6640.
Here is the program before and after, with 10% for Ivan. The procedure "before" has been fixed by hand: the intern rule is added to the transfer as well.
function monthlyPay(e) { if (e.role === "manager") { return e.salary + e.bonus; } else if (e.role === "intern") { return Math.trunc(e.salary / 2); } return e.salary;}
function transfer(staff) { let total = 0; for (const e of staff) { let amount = e.salary; if (e.role === "manager") { amount += e.bonus; } else if (e.role === "intern") { amount = Math.trunc(e.salary / 2); } console.log(`Transfer: ${e.name} €${amount}`); total += amount; } console.log(`Total: €${total}`);}const staff = [ new Employee("Ivan", "employee", 2000, 0), new Employee("Maria", "manager", 3000, 500), new Employee("Ana", "intern", 1600, 0),];const percent = 10;staff[0].salary += Math.trunc(staff[0].salary * percent / 100);for (const e of staff) { console.log(`Payslip: ${e.name} €${monthlyPay(e)}`);}transfer(staff);Payslip: Ivan €2200Payslip: Maria €3500Payslip: Ana €800Transfer: Ivan €2200Transfer: Maria €3500Transfer: Ana €800Total: €6500function transfer(payments) { let total = 0; for (const p of payments) { console.log(`Transfer: ${p.payee()} €${p.amountDue()}`); total += p.amountDue(); } console.log(`Total: €${total}`);}const staff = [ new Employee("Ivan", 2000), new Manager("Maria", 3000, 500), new Intern("Ana", 1600),];staff[0].raise(10);for (const e of staff) { console.log(`Payslip: ${e.name} €${e.monthlyPay()}`);}transfer(staff);Payslip: Ivan €2200Payslip: Maria €3500Payslip: Ana €800Transfer: Ivan €2200Transfer: Maria €3500Transfer: Ana €800Total: €6500The output is the same. The difference is in what happens tomorrow:
- Someone enters -10 →
raise()refuses it (encapsulation). - A new rule for raises → it changes in one place,
Employee(inheritance). - A new kind of employee → a new class with its own
monthlyPay(); the payslip and the transfer stay untouched (polymorphism). - A new thing to pay → a new class that fulfills the contract; the transfer stays untouched (abstraction).
The same program with the wrong -10% from the problem and with the invoice:
const staff = [ new Employee("Ivan", 2000), new Manager("Maria", 3000, 500), new Intern("Ana", 1600),];staff[0].raise(-10);for (const e of staff) { console.log(`Payslip: ${e.name} €${e.monthlyPay()}`);}const payments = [staff[0], staff[1], staff[2], new Invoice("Power company", 340)];transfer(payments);Payslip: Ivan €2000Payslip: Maria €3500Payslip: Ana €800Transfer: Ivan €2000Transfer: Maria €3500Transfer: Ana €800Transfer: Power company €340Total: €6640All the classes in one place
// The "can be paid" contract. Every payable object has:// amountDue(): how much is owed// payee(): to whom
class Employee { #name; #salary;
constructor(name, salary) { this.#name = name; this.#salary = salary; }
get name() { return this.#name; }
get salary() { return this.#salary; }
raise(percent) { if (percent > 0) { this.#salary += Math.trunc(this.#salary * percent / 100); } }
monthlyPay() { return this.#salary; }
amountDue() { return this.monthlyPay(); }
payee() { return this.#name; }}
class Manager extends Employee { #bonus;
constructor(name, salary, bonus) { super(name, salary); this.#bonus = bonus; }
get bonus() { return this.#bonus; }
monthlyPay() { return this.salary + this.#bonus; }}
class Intern extends Employee { monthlyPay() { return Math.trunc(this.salary / 2); }}
class Invoice { #supplier; #amount;
constructor(supplier, amount) { this.#supplier = supplier; this.#amount = amount; }
amountDue() { return this.#amount; }
payee() { return this.#supplier; }}Try it
Put the manager in order
The lines of Manager are shuffled. Put them in the usual order: the field, the constructor, then the pay method.
super(name, salary);class Manager extends Employee {this.#bonus = bonus;constructor(name, salary, bonus) {}#bonus;}}monthlyPay() {return this.salary + this.#bonus;
Try it
Find the bug
A colleague "simplified" the contract in Employee. The program is the one from "The four together", and it prints:
Payslip: Ivan €2200Payslip: Maria €3500Payslip: Ana €800Transfer: Ivan €2200Transfer: Maria €3000Transfer: Ana €1600Total: €6800Maria's payslip is €3500, and the bank sends her €3000. Which line is the bug on?
Pick the line with the bug.
amountDue() returns the salary directly instead of asking this.monthlyPay(). This is the second copy of the rule from the problem again, only shorter: the manager's bonus and the intern's half are left out. A parent method that calls an overridden method is where polymorphism works for you. The fix: return this.monthlyPay();.
The idea
What people mix up
Abstraction or encapsulation? Both hide something, but different things. Encapsulation hides the data of one object so that nobody outside can break it: the salary is hidden, the raise checks. Abstraction hides the differences between many classes behind a shared promise: the transfer sees only amountDue() and payee(). Encapsulation looks at the object from inside, abstraction looks at the code that uses it from outside.
Inheritance or composition? Inheritance says "is a": a manager is an employee. Composition says "has a": an employee has a bank account. If you make the employee a child of the account from the lesson "Encapsulation" to get deposit() for free,
class Employee extends Account { // ... name and salary}it will also get withdrawals, a balance and everything the account learns tomorrow. And "an employee is an account" is not true. Instead, the account is a field of the employee:
class Employee { #name; #salary; #account;
constructor(name, salary, account) { this.#name = name; this.#salary = salary; this.#account = account; }
payday() { this.#account.deposit(this.#salary); }}const account = new Account(100);const ivan = new Employee("Ivan", 2000, account);ivan.payday();console.log(account.balance);2100That is why the common advice is "prefer composition over inheritance". Inherit only when "X is a Y" is true and will stay true. Otherwise let the object have the other one as a field.
Overloading or overriding? In Java and C# both are sometimes called polymorphism. Overloading is several methods with one name and different parameters. JavaScript does not have it: a second function with the same name replaces the first, and instead there are default values (the lesson on them). Polymorphism in OOP is overriding: the choice is made by the object, while the program runs.
More precisely, if you are curious
- Some books list three pillars: encapsulation, inheritance and polymorphism. Abstraction is then the shared idea under them. In an interview, four is what is usually expected.
- The principles are not rules to follow "100%". Each has a cost: hidden fields mean more methods, hierarchies mean more classes, contracts mean one more name. You use them where the crack is real.
- After these four come the five SOLID principles for code that survives change, and design patterns: ready-made solutions built from the same four. They are in the "Software engineering" course: SOLID and Design patterns.
Exercise
Which principle is broken?
A classic interview question. For each snippet, answer out loud: which principle is broken, what will break, and how you fix it.
1.
class Cart { total = 0;}
// ... in 12 places in the program:cart.total -= discount;2.
function area(s) { if (s instanceof Circle) return 3.14 * s.r * s.r; if (s instanceof Rectangle) return s.w * s.h; return 0;}3.
class Invoice extends Employee { // gets name and payee() for free}4.
// the only implementation:class DefaultSalaryProvider { provide(e) { return e.monthlyPay(); }}
const salaries = new DefaultSalaryProvider();console.log(salaries.provide(maria));The answers
- Encapsulation. The total is open and gets reduced from 12 places. A discount bigger than the total makes it negative, and nobody asks. The total becomes #total, and the discount is a method of the cart that checks.
- Polymorphism (and abstraction). An if on the kind of shape: a new shape means a new line here and everywhere someone asks about the kind. The area becomes an abstract method of
Shape, and each shape writes its own. - Inheritance in the wrong place. "An invoice is an employee" is not true: it gets a salary, a raise and a monthly pay. What they share is that they get paid: a
Payablecontract. - Abstraction too early. A class that only forwards to
monthlyPay()is one more name and one more jump while reading, and it guards against nothing. Remove it and call the method directly. The contract will make sense when a second implementation appears.
Exercise
A contractor and a terrace
Solve these in your editor.
1. A contractor. Contractor (Peter, 40 hours at €25) is not an employee: there is no payslip, but he must be in the transfer. Add him so that transfer does not change by a single line. Put a rent invoice in the transfer as well, €1800 for the landlord.
Transfer: Ivan €2000Transfer: Maria €3500Transfer: Ana €800Transfer: Peter €1000Transfer: Landlord €1800Total: €91002. The terrace without if. This procedure works out the area by the kind of shape:
function area(kind, a, b) { if (kind === "rectangle") return a * b; if (kind === "triangle") return a * b / 2; if (kind === "square") return a * a; return 0;}Write it with an abstract Shape and the classes Rectangle, Triangle and Square. The terrace is a 4 × 3 rectangle, a triangle with base 2 and height 3, and a square with side 2. A square is a rectangle with equal sides: write it as a child of Rectangle, with no area formula of its own.
Rectangle: 12 m²Triangle: 3 m²Square: 4 m²Total: 19 m²More tasks
- A payslip for the contractor too. If the contractor wants a payslip after all, without becoming an employee: what has to be in the contract, and who fulfills it?
- Pay into an account. Give the employee an account (composition, as in "What people mix up") and a method that, on payday, deposits
monthlyPay(), not the salary. The manager must get the bonus too, without touchingmonthlyPay()inManager. - A raise for everyone. Raise every employee's salary by 5% in one loop. Which classes do you touch?
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Polymorphism · Abstract classes without abstract · Interfaces without interface · Type checks
- Tasks on this topic: Inheritance and polymorphism