Encapsulation
Encapsulation
LinkedIn Hook
Here is a question that trips up a lot of developers in interviews:
"What is encapsulation?"
Most people answer: "It's about making things private."
That answer is incomplete — and interviewers know it.
Encapsulation has two parts that always go together. First, you bundle data and the methods that operate on that data into a single unit (a class). Second, you control who can access or change that data from the outside.
If you only hide data without exposing a proper interface, your object is useless. If you expose everything without protection, you have no encapsulation at all.
The bank account example makes this concrete. A balance is not just a number. It is a number with rules: you can't set it to -Infinity, you can't deposit a string, you can't bypass the audit log. Encapsulation is how you enforce those rules — in the class, consistently, for every caller.
In this lesson I cover what encapsulation actually is, why bundling matters as much as hiding, and how to implement it cleanly in modern JavaScript using
#privateFieldsyntax.This is the kind of answer that makes an interviewer stop and say "good, let's go deeper."
Read the full lesson -> [link]
#OOP #JavaScript #SoftwareEngineering #InterviewPrep
What You'll Learn
- What encapsulation is, and why bundling is as important as hiding
- How to implement encapsulation in JavaScript using
#privateField(ES2022) - How the public interface pattern protects internal state from invalid changes
What is Encapsulation?
Analogy: A Bank Vault with a Teller Window
Imagine a bank. Behind the counter sits a vault. The vault holds cash, ledgers, and transaction records. You, as a customer, never touch any of it directly. You speak with a teller. The teller takes your request, validates it, and performs the operation on the vault according to the bank's rules.
That is encapsulation.
The vault is the internal state. The teller is the public interface. The wall between them is the access control. You get the outcome you need without ever having direct access to what is inside.
Now imagine the opposite: the vault door is open, and anyone walking past can reach in and change the balance on any account. Rules collapse. Audits are meaningless. The system breaks.
That is what happens in code when you expose raw internal state and let callers modify it directly.
Encapsulation is the design decision to not do that.
The Two-Part Definition Interviewers Want
Encapsulation combines two things that always work together:
- Bundling — grouping data (fields) and the behavior that operates on that data (methods) into a single class.
- Hiding — restricting direct access to that data, so callers must go through the class's controlled interface.
Most developers only mention hiding. The bundling part is equally important. A class that hides everything but exposes no useful interface is a black box. A class that bundles data and methods together without hiding state is a leaky object. You need both.
Citation Capsule: Encapsulation is defined in Grady Booch's foundational work Object-Oriented Analysis and Design (Booch, 1994) as "the process of hiding all the details of an object that do not contribute to its essential characteristics." The key word is "essential" — only the interface that callers need should be visible.
[INTERNAL-LINK: "four pillars overview" -> ch01-fundamentals/03-four-pillars-overview.md]
Why Does Hiding Internal State Matter?
The Problem With Direct Access
When you allow direct access to internal fields, you lose control over when and how they change. Any caller can assign any value, bypass validation, skip side effects like logging, and leave the object in an inconsistent state.
The cost is subtle at first. One developer sets account.balance = -500 during testing. Another sets account.owner = null to clear a reference. A third reads account._transactions directly and builds UI logic that depends on the internal array structure. Now your internals are a public contract you cannot change without breaking callers.
// Without encapsulation: direct field access
class BankAccountBad {
constructor(owner, initialBalance) {
this.owner = owner;
this.balance = initialBalance; // public: anyone can write to this
this.transactions = []; // public: anyone can mutate this array
}
}
const account = new BankAccountBad("Alice", 1000);
// All of these are allowed — and all of them can break things
account.balance = -99999; // negative balance with no validation
account.transactions = null; // destroyed the transaction history
account.owner = undefined; // corrupted the owner reference
console.log(account.balance); // -99999 -- invalid, but JavaScript allows it
No errors. No warnings. The object is silently invalid.
Implementing Encapsulation in JavaScript
Using #privateField (ES2022)
JavaScript added true private class fields in ES2022 using the # prefix. Fields declared with # are hard-private: they cannot be accessed from outside the class at all. The access attempt throws a SyntaxError at parse time, not a runtime error. The engine rejects the code before it even runs.
class BankAccount {
// Private fields: declared at the top of the class body
// Only accessible from within this class
#balance;
#owner;
#transactions;
constructor(owner, initialBalance) {
if (typeof owner !== "string" || owner.trim() === "") {
throw new Error("Owner must be a non-empty string");
}
if (typeof initialBalance !== "number" || initialBalance < 0) {
throw new Error("Initial balance must be a non-negative number");
}
this.#owner = owner;
this.#balance = initialBalance;
this.#transactions = [];
this.#log("account opened", initialBalance);
}
// Private helper method: # applies to methods too
#log(action, amount) {
this.#transactions.push({
action,
amount,
timestamp: Date.now(),
balanceAfter: this.#balance,
});
}
// Public interface: the teller window
deposit(amount) {
if (typeof amount !== "number" || amount <= 0) {
throw new Error("Deposit amount must be a positive number");
}
this.#balance += amount;
this.#log("deposit", amount);
return this; // enables method chaining
}
withdraw(amount) {
if (typeof amount !== "number" || amount <= 0) {
throw new Error("Withdrawal amount must be a positive number");
}
if (amount > this.#balance) {
throw new Error("Insufficient funds");
}
this.#balance -= amount;
this.#log("withdrawal", amount);
return this;
}
getBalance() {
return this.#balance;
}
getOwner() {
return this.#owner;
}
// Returns a copy of transactions, not the original array
// Caller cannot mutate internal history
getTransactionHistory() {
return [...this.#transactions];
}
}
const account = new BankAccount("Alice", 1000);
account.deposit(500).withdraw(200); // method chaining works
console.log(account.getBalance()); // 1300
console.log(account.getOwner()); // "Alice"
console.log(account.getTransactionHistory().length); // 3 (open, deposit, withdrawal)
// All of these now fail -- encapsulation is enforced
// account.#balance = -99999; // SyntaxError: Private field '#balance' must be declared
// account.#transactions = []; // SyntaxError at parse time -- cannot even run
Notice several things in this implementation. Validation lives in the class and runs on every operation. The transaction log is automatic and cannot be bypassed. The getTransactionHistory() method returns a copy of the array so the caller cannot mutate the original. The constructor itself validates inputs before setting any state.
[INTERNAL-LINK: "#privateField syntax deep dive" -> ch02-encapsulation/03-private-in-javascript.md]
Public Interface vs Internal Implementation
What Should Be Public, What Should Be Private
The rule is simple: expose what callers need to use the object, hide everything else. Internal state is almost always private. Helper methods used only inside the class are private. Anything that represents the class's contract with the outside world is public.
class PasswordManager {
#rawPassword; // private: never expose plaintext
#hashedPassword; // private: internal representation
#saltRounds = 10; // private: implementation detail
constructor(password) {
this.#rawPassword = password;
this.#hashedPassword = this.#hash(password); // uses private method
this.#rawPassword = null; // zero out plaintext immediately after hashing
}
// Private: implementation detail -- callers don't need to know HOW hashing works
#hash(password) {
// Simplified -- in production you'd use bcrypt or argon2
return `hashed_${password}_rounds_${this.#saltRounds}`;
}
// Public interface: callers only need to verify, not to hash
verify(inputPassword) {
return this.#hash(inputPassword) === this.#hashedPassword;
}
// Public interface: safe to expose -- returns nothing sensitive
isSet() {
return this.#hashedPassword !== null;
}
// There is no getPassword() -- and there should never be one
}
const pm = new PasswordManager("secret123");
console.log(pm.verify("secret123")); // true
console.log(pm.verify("wrong")); // false
console.log(pm.isSet()); // true
// pm.#rawPassword -- SyntaxError: cannot access from outside
// pm.#hash("x") -- SyntaxError: private methods are inaccessible too
The PasswordManager exposes only two methods: verify and isSet. Neither reveals how passwords are stored or hashed. If you later switch the hashing algorithm from SHA-256 to bcrypt to argon2, no callers change. The internal implementation is free to evolve because it is hidden.
[PERSONAL EXPERIENCE] In production codebases, the most common encapsulation failure is not forgetting to use # prefix. It is forgetting to return copies of internal arrays and objects from getter methods. A class that stores #items = [] and returns it directly with getItems() { return this.#items; } has given the caller a live reference to private state. The caller can push to it, splice it, or sort it, and the class never knows. Always return [...this.#items] or this.#items.map(...) when exposing internal collections.
The Tricky Edge Case: Returning Object References
This is the encapsulation trap interviewers often set. A class can use #privateField correctly and still leak internal state by returning a reference to a mutable object.
class ShoppingCart {
#items = [];
addItem(item) {
this.#items.push(item);
}
// BUGGY: returns a direct reference to the private array
getItemsBuggy() {
return this.#items;
}
// CORRECT: returns a shallow copy -- caller cannot mutate the original
getItems() {
return [...this.#items];
}
getCount() {
return this.#items.length;
}
}
const cart = new ShoppingCart();
cart.addItem({ name: "Laptop", price: 999 });
cart.addItem({ name: "Mouse", price: 29 });
console.log(cart.getCount()); // 2
// Exploit the buggy getter to bypass encapsulation without touching #items directly
const leaked = cart.getItemsBuggy();
leaked.push({ name: "Free TV", price: 1500 }); // mutating "private" state from outside
console.log(cart.getCount()); // 3 -- private state was modified from outside the class!
// Using the correct getter: caller gets a copy, original is safe
const safe = cart.getItems();
safe.push({ name: "Hacked Item", price: 0 });
console.log(cart.getCount()); // still 3 -- private array was not affected
[UNIQUE INSIGHT] The #privateField syntax prevents direct property access, but it does not make the referenced data immutable. A private field holding an object or array can still be mutated by any caller who receives a reference to it. True encapsulation requires both the private field AND defensive copying in any method that returns mutable internal data. The two protections operate at different levels: one protects the reference, the other protects the data the reference points to.
Common Mistakes
Mistake 1: Confusing Privacy With Encapsulation
Using #privateField alone does not mean your class is well-encapsulated. Encapsulation is a design principle. Privacy is a tool. A class can use #private everywhere and still expose all its state through poorly designed public getters that return mutable references. Encapsulation means the public interface is intentional and the internal state is genuinely protected from invalid modification.
Mistake 2: Returning Live References From Getters
class Config {
#settings = { theme: "dark", language: "en" };
// WRONG: caller receives a reference to the private object
// They can set config.getSettings().theme = "corrupted" and it sticks
getSettings() {
return this.#settings;
}
// CORRECT: return a shallow copy so callers get a snapshot, not a handle
getSettingsSafe() {
return { ...this.#settings };
}
// CORRECT alternative: return only what the caller needs
getSetting(key) {
return this.#settings[key];
}
}
For nested objects, use structuredClone(this.#settings) instead of a shallow spread to ensure deep properties are also copies.
Mistake 3: Putting Validation Only in the Constructor
If deposit() and withdraw() do not validate their inputs, a user can corrupt the balance after the object is created. Validation must live at every entry point into the object's state, not only at construction time. This is exactly what the BankAccount example above does: every public method validates before mutating.
Interview Questions
Q: What is encapsulation in OOP?
Encapsulation is the combination of two related practices: bundling data (fields) and the methods that operate on that data into a single unit (a class), and restricting direct access to that internal data so callers must go through a controlled public interface. The first part ensures related logic lives together. The second part ensures that logic enforces rules and prevents invalid state.
Q: What is the difference between encapsulation and abstraction?
Encapsulation is about protecting internal state and enforcing rules on how it changes. Abstraction is about hiding complexity and exposing a simplified model to callers. They are complementary. A well-encapsulated class often also abstracts away complexity. But encapsulation specifically refers to bundling and access control, while abstraction refers to the level of detail exposed in the interface.
[INTERNAL-LINK: "Abstraction lesson" -> ch03-abstraction/01-abstraction.md]
Q: How do you implement encapsulation in JavaScript?
Using the
#privateFieldsyntax (ES2022). Fields and methods declared with a#prefix are hard-private: they cannot be accessed or assigned from outside the class. Any attempt to access them throws aSyntaxErrorat parse time. For environments that don't support ES2022, the common alternatives are the_convention(convention-only, not enforced) or theWeakMappattern (true privacy via external storage).
Q: Can you break encapsulation even with #private fields?
Yes. Private fields prevent direct property access, but they don't prevent a caller from mutating data they received by reference from a getter. If a private field holds an array and a public method returns that array directly, the caller can mutate the array without going through any class method. The fix is to return copies from getters rather than direct references. This is the most common real-world encapsulation failure.
Q: Why does encapsulation matter for maintenance and testing?
When internal state can only change through a class's own methods, you have exactly one place to add validation, logging, or side effects. If you later need every balance change to trigger an audit event, you add it to
deposit()andwithdraw()once. Without encapsulation, every call site in the entire codebase would need the same change. For testing, encapsulation means you can test the class through its public interface without knowing or depending on the internal representation.
Quick Reference — Cheat Sheet
+------------------------------+-----------------------------------------------+
| Concept | Key Points |
+------------------------------+-----------------------------------------------+
| What encapsulation is | 1. Bundle data + methods into one class |
| | 2. Restrict direct access to internal data |
| | Both parts are required |
+------------------------------+-----------------------------------------------+
| #privateField (ES2022) | Declared at top of class body with # prefix |
| | class Foo { #bar; ... } |
| | SyntaxError if accessed from outside |
| | Applies to fields AND methods |
| | Not accessible in subclasses either |
+------------------------------+-----------------------------------------------+
| Public interface rules | Expose what callers need to use the object |
| | Hide everything else (#private) |
| | Validate all inputs at every public method |
| | Return copies, not live references |
+------------------------------+-----------------------------------------------+
| The reference leak trap | #field is private, but object/array it holds |
| | can still be mutated if you return it raw |
| | Fix: return [...this.#arr] or {...this.#obj} |
| | Deep: use structuredClone() |
+------------------------------+-----------------------------------------------+
| Encapsulation vs Abstraction | Encapsulation: bundling + access control |
| | Abstraction: hiding complexity, exposing |
| | simplified interface |
| | Complementary, but different concerns |
+------------------------------+-----------------------------------------------+
| Common failure patterns | Validation only in constructor, not methods |
| | Returning live array/object references |
| | Using _ prefix (convention, not enforced) |
| | No public interface -- everything is private |
+------------------------------+-----------------------------------------------+
RULE: Data and the methods that change it belong in the same class.
RULE: If a caller can set a field to an invalid value, you have no encapsulation.
RULE: Returning a private array by reference gives the caller a key to the vault.
Previous: Lesson 1.5 — Object Lifecycle -> Next: Lesson 2.2 — Access Modifiers ->
This is Lesson 2.1 of the OOP Interview Prep Course — 8 chapters, 41 lessons.