A Practical Guide to FP, OOP, Prototypes, Composition, Currying, Immutability, and Encapsulation
Modern JavaScript supports multiple programming paradigms. Two of the most important are:
- Object-Oriented Programming (OOP)
- Functional Programming (FP)
JavaScript is particularly interesting because it is a multi-paradigm language. You can build applications using classes and inheritance, functional composition and higher-order functions, or combine both approaches.
Understanding these paradigms is more important than simply knowing their syntax. The real skill is knowing how the underlying concepts affect architecture, state management, testability, reusability, and maintainability.
This article explores the major concepts behind both paradigms and shows how they work internally in JavaScript.
Table of Contents
- Programming Paradigms
- Functional Programming
- Object-Oriented Programming
- Abstract Classes and Inheritance
- Prototypes and Constructors
- Composition Over Inheritance
- Functional Composition
- Factory Functions
- Currying
- Partial Application
- Higher-Order Functions
- Lambda Functions
- Recursive Functions
- Pure Functions
- Immutable vs Mutable State
- Private Properties and Methods
- FP vs OOP
- Using FP and OOP Together
- Real-World Architecture Example
- Best Practices
- Conclusion
1. Programming Paradigms
A programming paradigm is a way of thinking about how software should be structured.
Instead of asking:
"What syntax should I use?"
we should ask:
"How should I model the problem?"
Two major approaches are:
Programming
│
├── Imperative
│ ├── Procedural
│ └── Object-Oriented
│
└── Declarative
└── Functional
Consider a simple requirement:
Calculate the total price of products.
An imperative approach might say:
let total = 0;
for (const product of products) {
total += product.price;
}
A functional approach describes the transformation:
const total = products.reduce(
(sum, product) => sum + product.price,
0
);
The difference is not just syntax.
It is about how we reason about the program.
2. Functional Programming
Functional Programming treats computation primarily as the evaluation of functions.
The central idea is:
Build programs by composing small functions that transform data.
For example:
const addTax = price => price * 1.14;
const addShipping = price => price + 100;
const calculateOrderTotal = price =>
addShipping(addTax(price));
console.log(calculateOrderTotal(1000));
Each function performs one transformation.
Conceptually:
price
↓
addTax
↓
addShipping
↓
result
Functional programming emphasizes:
- Pure functions
- Immutability
- Function composition
- Higher-order functions
- Referential transparency
- Declarative programming
- Avoiding shared mutable state
3. Object-Oriented Programming
Object-Oriented Programming organizes software around objects.
An object combines:
State + Behavior
For example:
class BankAccount {
constructor(balance) {
this.balance = balance;
}
deposit(amount) {
this.balance += amount;
}
withdraw(amount) {
if (amount > this.balance) {
throw new Error("Insufficient funds");
}
this.balance -= amount;
}
}
Usage:
const account = new BankAccount(1000);
account.deposit(500);
account.withdraw(200);
console.log(account.balance);
The object owns both:
State
↓
balance
Behavior
↓
deposit()
withdraw()
OOP commonly uses:
- Encapsulation
- Abstraction
- Inheritance
- Polymorphism
- Composition
4. Abstract Classes and Inheritance
Abstraction means exposing what an object can do while hiding implementation details.
For example:
class PaymentProcessor {
processPayment(amount) {
throw new Error("processPayment() must be implemented");
}
}
Then:
class StripePayment extends PaymentProcessor {
processPayment(amount) {
console.log(`Processing ${amount} using Stripe`);
}
}
And:
class PayPalPayment extends PaymentProcessor {
processPayment(amount) {
console.log(`Processing ${amount} using PayPal`);
}
}
Now we can work with the abstraction:
function checkout(processor, amount) {
processor.processPayment(amount);
}
Usage:
checkout(new StripePayment(), 500);
checkout(new PayPalPayment(), 500);
This demonstrates polymorphism.
The caller doesn't need to know the concrete implementation.
Important JavaScript Difference
Unlike Java or C#, JavaScript does not have a traditional abstract class keyword.
We usually simulate abstraction using:
class PaymentProcessor {
processPayment() {
throw new Error("Not implemented");
}
}
Or we can use TypeScript:
abstract class PaymentProcessor {
abstract processPayment(amount: number): void;
}
class StripePayment extends PaymentProcessor {
processPayment(amount: number) {
console.log(amount);
}
}
5. Prototypes and Constructors
JavaScript is fundamentally prototype-based.
Classes are largely syntactic sugar over JavaScript's prototype system.
Consider:
function User(name) {
this.name = name;
}
User.prototype.sayHello = function () {
console.log(`Hello ${this.name}`);
};
Now:
const user1 = new User("John");
const user2 = new User("Alice");
Both objects can access:
user1.sayHello();
user2.sayHello();
But the method does not need to be copied into every object.
Instead:
user1
│
▼
User.prototype
│
└── sayHello()
user2
│
▼
User.prototype
│
└── sayHello()
This is the prototype chain.
What Does new Do?
When we write:
const user = new User("John");
JavaScript roughly performs these steps:
1. Create a new object
2. Link object to User.prototype
3. Execute User with `this`
4. Return the object
Conceptually:
const user = Object.create(User.prototype);
User.call(user, "John");
This is not a complete specification-level implementation, but it provides the right mental model.
6. Composition Over Inheritance
Inheritance can become problematic when hierarchies become deep.
Imagine:
Animal
↓
Mammal
↓
FlyingMammal
↓
FlyingSwimmingMammal
As requirements grow, the hierarchy becomes difficult to maintain.
Composition takes another approach.
Instead of asking:
"What is this object?"
we ask:
"What capabilities does this object have?"
For example:
const canWalk = state => ({
walk() {
console.log(`${state.name} is walking`);
}
});
const canFly = state => ({
fly() {
console.log(`${state.name} is flying`);
}
});
const canSwim = state => ({
swim() {
console.log(`${state.name} is swimming`);
}
});
Now:
const bird = {
name: "Eagle",
...canWalk({ name: "Eagle" }),
...canFly({ name: "Eagle" })
};
And:
const duck = {
name: "Duck",
...canWalk({ name: "Duck" }),
...canFly({ name: "Duck" }),
...canSwim({ name: "Duck" })
};
Instead of building a large inheritance tree, we compose capabilities.
7. Functional Composition
Functional composition means combining functions to create a new function.
Suppose we have:
const double = x => x * 2;
const square = x => x * x;
We can compose them:
const doubleThenSquare = x =>
square(double(x));
Then:
doubleThenSquare(3);
Execution:
3
↓
double
↓
6
↓
square
↓
36
Building a compose Function
We can generalize the concept:
const compose = (...functions) =>
value =>
functions.reduceRight(
(result, fn) => fn(result),
value
);
Now:
const doubleThenSquare = compose(
square,
double
);
console.log(doubleThenSquare(3));
Result:
36
We can also create pipe, which executes from left to right:
const pipe = (...functions) =>
value =>
functions.reduce(
(result, fn) => fn(result),
value
);
Then:
const processPrice = pipe(
price => price * 1.14,
price => price + 100,
price => Math.round(price)
);
Usage:
processPrice(1000);
This style is very common in functional JavaScript.
8. Factory Functions
A factory function is a function that creates objects.
Instead of:
class User {
constructor(name) {
this.name = name;
}
greet() {
return `Hello ${this.name}`;
}
}
we can use:
function createUser(name) {
return {
name,
greet() {
return `Hello ${this.name}`;
}
};
}
Usage:
const user = createUser("John");
console.log(user.greet());
Factory functions are particularly useful when combined with composition.
Factory + Composition
const createUser = name => ({
name
});
const withAuthentication = user => ({
...user,
login() {
console.log(`${user.name} logged in`);
}
});
const withPermissions = user => ({
...user,
hasPermission(permission) {
return permission === "admin";
}
});
Now:
const admin = withPermissions(
withAuthentication(
createUser("John")
)
);
This gives us a composable object.
9. Currying
Currying transforms a function that takes multiple arguments into a sequence of functions that each take one argument.
Normal function:
const add = (a, b) => a + b;
Curried version:
const add = a => b => a + b;
Usage:
add(10)(20);
Result:
30
Why Is Currying Useful?
Because it allows us to specialize functions.
For example:
const multiply = a => b => a * b;
const double = multiply(2);
const triple = multiply(3);
Now:
double(10); // 20
triple(10); // 30
We created specialized functions from a general function.
10. Partial Application
Currying and partial application are related but not identical.
Partial application means:
Fix some arguments now and provide the rest later.
Consider:
const createUser = (role, name, age) => ({
role,
name,
age
});
We can specialize the role:
const createAdmin = (name, age) =>
createUser("admin", name, age);
Now:
createAdmin("John", 30);
Conceptually:
createUser
│
├── role = admin
│
▼
createAdmin
Partial application is useful for creating reusable domain-specific functions.
11. Higher-Order Functions
A higher-order function is a function that:
- Receives another function as an argument
- Returns a function
- Or both
For example:
function executeOperation(a, b, operation) {
return operation(a, b);
}
Usage:
executeOperation(10, 20, (a, b) => a + b);
Result:
30
JavaScript's array methods are excellent examples:
map()
filter()
reduce()
some()
every()
find()
For example:
const users = [
{ name: "John", active: true },
{ name: "Alice", active: false },
{ name: "Bob", active: true }
];
const activeUsers = users.filter(
user => user.active
);
filter is a higher-order function because it receives a function.
12. Lambda Functions
In JavaScript, lambda functions are generally represented by arrow functions.
Example:
const square = x => x * x;
Compared to:
function square(x) {
return x * x;
}
Arrow functions provide concise syntax.
They are especially useful for callbacks:
users.map(user => user.name);
Important: Arrow Functions and this
Arrow functions do not create their own this.
Example:
const user = {
name: "John",
regular() {
console.log(this.name);
},
arrow: () => {
console.log(this.name);
}
};
The regular method receives this based on how it is called.
The arrow function captures this lexically from the surrounding scope.
This difference becomes extremely important in JavaScript applications.
13. Recursive Functions
A recursive function calls itself.
The classic example is factorial:
function factorial(n) {
if (n === 0) {
return 1;
}
return n * factorial(n - 1);
}
Execution:
factorial(4)
4 × factorial(3)
4 × 3 × factorial(2)
4 × 3 × 2 × factorial(1)
4 × 3 × 2 × 1
24
The critical part is the base case:
if (n === 0) {
return 1;
}
Without a base case, recursion can continue indefinitely until the call stack is exhausted.
Recursive Tree Traversal
Recursion becomes especially useful when working with hierarchical data.
const tree = {
value: 1,
children: [
{
value: 2,
children: []
},
{
value: 3,
children: [
{
value: 4,
children: []
}
]
}
]
};
We can traverse it recursively:
function traverse(node) {
console.log(node.value);
for (const child of node.children) {
traverse(child);
}
}
This pattern appears in:
- File systems
- DOM trees
- ASTs
- Organizational structures
- Dependency graphs
- Nested menus
14. Pure Functions
A pure function has two important properties:
1. Same input → same output
const add = (a, b) => a + b;
For:
add(2, 3);
the result will always be:
5
2. No side effects
A pure function doesn't modify external state.
Impure:
let total = 0;
function add(amount) {
total += amount;
}
The function changes external state.
Pure:
function add(total, amount) {
return total + amount;
}
Now the state is explicit.
Why Pure Functions Matter
Pure functions are easier to:
- Test
- Debug
- Cache
- Reason about
- Parallelize
- Reuse
For example:
expect(add(10, 20)).toBe(30);
There is no dependency on external state.
15. Immutable vs Mutable State
Mutable state means changing existing data.
const user = {
name: "John",
age: 30
};
user.age = 31;
The same object was modified.
With immutable updates:
const updatedUser = {
...user,
age: 31
};
Now:
user
↓
original object
updatedUser
↓
new object
The original remains unchanged.
Why Immutability Matters
Immutability makes state transitions easier to reason about.
Consider:
const state1 = {
count: 0
};
const state2 = {
...state1,
count: state1.count + 1
};
const state3 = {
...state2,
count: state2.count + 1
};
We now have:
state1 → state2 → state3
This idea is fundamental to state-management systems.
For example, reducer-style state updates:
function reducer(state, action) {
switch (action.type) {
case "INCREMENT":
return {
...state,
count: state.count + 1
};
default:
return state;
}
}
Instead of modifying state directly, the reducer returns a new state.
Mutable State Isn't Always Bad
It is important not to turn immutability into a dogma.
Mutation can sometimes be more efficient.
For example:
const array = [];
for (let i = 0; i < 1_000_000; i++) {
array.push(i);
}
Repeatedly creating new arrays could introduce unnecessary allocations.
The important question is:
Where is mutation allowed, and who owns it?
A practical architecture may use:
Immutable at boundaries
↓
Controlled mutation internally
↓
Immutable result
This gives you predictable APIs without forcing every internal operation to allocate new objects.
16. Private Properties and Methods
Encapsulation means hiding internal implementation details.
Modern JavaScript supports private class fields using #.
class BankAccount {
#balance;
constructor(balance) {
this.#balance = balance;
}
deposit(amount) {
this.#balance += amount;
}
getBalance() {
return this.#balance;
}
}
Now:
const account = new BankAccount(1000);
account.deposit(500);
console.log(account.getBalance());
But:
account.#balance;
is invalid.
The property is truly private.
Private Methods
JavaScript also supports private methods:
class UserService {
#validate(user) {
if (!user.email) {
throw new Error("Email is required");
}
}
createUser(user) {
this.#validate(user);
console.log("User created");
}
}
The caller can use:
service.createUser(user);
but cannot directly call:
service.#validate(user);
17. FP vs OOP
Let's compare the mental models.
| Concept | Functional Programming | Object-Oriented Programming |
|---|---|---|
| Main abstraction | Functions | Objects |
| Primary focus | Data transformations | State + behavior |
| State | Prefer immutable | Often encapsulated/mutable |
| Reuse | Composition | Inheritance/composition |
| Side effects | Minimized | Encapsulated |
| Polymorphism | Higher-order functions / protocols | Inheritance / interfaces |
| Data flow | Explicit | Often object-centered |
| Testing | Pure functions are easy | Objects require state setup |
| Best for | Transformations | Stateful domains |
But this table should not lead to:
"FP is better."
or:
"OOP is better."
The better question is:
"Which model fits this problem?"
18. Using FP and OOP Together
Real-world JavaScript applications frequently combine both paradigms.
For example:
class UserService {
constructor(repository) {
this.repository = repository;
}
async getActiveUsers() {
const users = await this.repository.findAll();
return users
.filter(user => user.active)
.map(user => ({
id: user.id,
name: user.name
}));
}
}
The service uses OOP:
class UserService
and functional programming internally:
.filter(...)
.map(...)
This is often a very practical approach.
19. Real-World Architecture Example
Consider an e-commerce system.
We might have:
OrderService
│
├── Repository
│
├── PaymentService
│
└── NotificationService
OOP can model long-lived services:
class OrderService {
constructor(orderRepository, paymentService) {
this.orderRepository = orderRepository;
this.paymentService = paymentService;
}
async createOrder(order) {
// orchestration
}
}
FP can handle transformations:
const calculateSubtotal = items =>
items.reduce(
(total, item) =>
total + item.price * item.quantity,
0
);
Tax:
const calculateTax = rate => subtotal =>
subtotal * rate;
Discount:
const applyDiscount = discount => total =>
total - discount;
Then compose the calculation:
const calculateTotal = (items, taxRate, discount) => {
const subtotal = calculateSubtotal(items);
const tax = calculateTax(taxRate)(subtotal);
return applyDiscount(discount)(
subtotal + tax
);
};
The architecture becomes:
OOP
│
▼
Application Services
│
▼
FP
│
▼
Pure Domain Logic
│
▼
Data
This separation can be extremely powerful.
20. A More Advanced Example
Suppose we need to process orders.
Raw data:
const orders = [
{
id: 1,
status: "completed",
total: 500
},
{
id: 2,
status: "pending",
total: 300
},
{
id: 3,
status: "completed",
total: 700
}
];
We can build small pure functions:
const isCompleted = order =>
order.status === "completed";
const getTotal = order =>
order.total;
const sum = (a, b) =>
a + b;
Then:
const revenue = orders
.filter(isCompleted)
.map(getTotal)
.reduce(sum, 0);
Result:
1200
Each function has one responsibility.
This makes the code easy to test:
isCompleted(orders[0]); // true
getTotal(orders[0]); // 500
sum(500, 700); // 1200
21. A Practical Decision Framework
Instead of choosing FP or OOP based on preference, consider the problem.
Use Functional Programming heavily when:
- Data transformation is the main problem.
- You have pipelines of operations.
- You need predictable business rules.
- You want highly testable logic.
- You want to minimize shared state.
Examples:
Data processing
Validation
Calculations
Reducers
Transformations
Parsing
Mapping
Filtering
Aggregation
Use OOP heavily when:
- You have long-lived entities.
- Objects own state.
- You need encapsulation.
- You have complex domain behavior.
- You need dependency injection.
- You are modeling external resources/services.
Examples:
PaymentService
DatabaseRepository
FileStorage
ConnectionPool
Order
UserSession
Cache
Use Composition when:
You have reusable capabilities.
Instead of:
BaseUser
↓
AdminUser
↓
SuperAdminUser
consider:
User
+ Authentication
+ Authorization
+ Logging
+ Auditing
22. The Deeper Idea: Data vs Behavior
One of the biggest philosophical differences between FP and OOP is how they organize behavior.
OOP tends to ask:
"Who owns this behavior?"
For example:
order.calculateTotal();
The behavior belongs to order.
FP tends to ask:
"What transformation should happen to this data?"
For example:
calculateTotal(order);
Neither approach is universally correct.
They represent two different ways of modeling the same problem.
23. Referential Transparency
A function is referentially transparent when we can replace the function call with its result without changing program behavior.
For example:
const square = x => x * x;
Then:
square(5)
can always be replaced with:
25
Consider:
const result = square(5) + square(5);
We can reason about it as:
const result = 25 + 25;
This property makes functional code easier to reason about.
Compare that with:
let counter = 0;
function increment() {
return ++counter;
}
Calling:
increment();
cannot be replaced with a fixed value because the result depends on external state.
24. Closures and Functional Programming
Closures are another fundamental JavaScript feature.
Consider:
function createCounter() {
let count = 0;
return function () {
count++;
return count;
};
}
Usage:
const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3
The returned function remembers:
count
even after createCounter() has finished.
This creates private state without using a class.
25. Factory Functions + Closures
This allows us to build encapsulated objects using FP techniques.
function createBankAccount(initialBalance) {
let balance = initialBalance;
return {
deposit(amount) {
balance += amount;
},
withdraw(amount) {
if (amount > balance) {
throw new Error("Insufficient funds");
}
balance -= amount;
},
getBalance() {
return balance;
}
};
}
Now:
const account = createBankAccount(1000);
account.deposit(500);
console.log(account.getBalance());
But:
account.balance;
is:
undefined
because balance exists only inside the closure.
This is an alternative form of encapsulation.
26. OOP Encapsulation vs Closure Encapsulation
OOP:
class BankAccount {
#balance;
constructor(balance) {
this.#balance = balance;
}
}
Functional:
function createBankAccount(balance) {
return {
getBalance() {
return balance;
}
};
}
Both can provide encapsulation.
The difference is the underlying model.
OOP
Object
├── State
└── Methods
FP
Closure
├── Captured State
└── Functions
27. A Production-Oriented Hybrid Design
A mature JavaScript application might look like:
┌──────────────────────────────┐
│ Controllers │
└──────────────┬───────────────┘
│
┌──────────────▼───────────────┐
│ Application Services │
│ OOP │
└──────────────┬───────────────┘
│
┌──────────────▼───────────────┐
│ Domain Functions │
│ FP │
│ │
│ Pure calculations │
│ Validation │
│ Transformations │
└──────────────┬───────────────┘
│
┌──────────────▼───────────────┐
│ Infrastructure │
│ │
│ Database │
│ Redis │
│ APIs │
└──────────────────────────────┘
The important architectural idea is:
Keep business logic as pure as possible and isolate side effects.
For example:
const calculateOrderTotal = order => {
// pure
};
class OrderService {
constructor(repository) {
this.repository = repository;
}
async createOrder(order) {
const total = calculateOrderTotal(order);
return this.repository.save({
...order,
total
});
}
}
The calculation is pure.
The repository handles the side effect.
28. Practical Rules
When writing JavaScript applications, these rules are useful.
Rule 1: Prefer small functions
Instead of:
processEverything();
prefer:
validateOrder();
calculateTotal();
applyDiscount();
saveOrder();
sendNotification();
Rule 2: Minimize shared mutable state
Shared mutable state creates hidden dependencies.
Prefer:
const newState = updateState(oldState);
over uncontrolled global mutation.
Rule 3: Prefer composition over deep inheritance
Instead of:
A
└── B
└── C
└── D
consider:
A + capability1 + capability2 + capability3
Rule 4: Keep side effects at the boundaries
Pure:
const calculateTax = (price, rate) =>
price * rate;
Side effect:
await database.save(order);
Don't mix them unnecessarily.
Rule 5: Use encapsulation where state has invariants
For example:
class BankAccount {
#balance;
withdraw(amount) {
if (amount > this.#balance) {
throw new Error("Insufficient funds");
}
this.#balance -= amount;
}
}
The object protects its invariant.
29. Final Mental Model
The easiest way to remember the concepts is:
FUNCTIONAL PROGRAMMING
│
├── Functions
├── Pure Functions
├── Immutability
├── Composition
├── Currying
├── Higher-Order Functions
└── Recursion
OBJECT-ORIENTED PROGRAMMING
│
├── Objects
├── Encapsulation
├── Abstraction
├── Inheritance
├── Polymorphism
└── Composition
JAVASCRIPT
│
├── Prototype-based
├── First-class Functions
├── Closures
├── Classes
├── Functional Features
└── Object-oriented Features
The most important lesson is that JavaScript does not force you to choose one paradigm.
You can combine them.
For example:
class OrderService {
constructor(repository) {
this.repository = repository;
}
async process(order) {
const validated = validateOrder(order);
const total = calculateTotal(validated);
const finalOrder = {
...validated,
total
};
return this.repository.save(finalOrder);
}
}
Here:
-
OrderServiceuses OOP -
validateOrdercan be a pure function -
calculateTotalcan be functional -
{ ...validated }uses immutable-style updates -
repositoryis encapsulated through the service -
save()represents a side effect
This hybrid style is often more useful in real-world applications than trying to build an entire system using only FP or only OOP.
Conclusion
Functional Programming and Object-Oriented Programming are not simply collections of syntax features. They are different ways of reasoning about software.
FP focuses on:
Transforming data through predictable functions.
OOP focuses on:
Encapsulating state and behavior inside objects.
JavaScript gives developers the ability to use both.
Understanding prototypes explains how JavaScript objects actually work. Understanding composition helps avoid unnecessarily complex inheritance hierarchies. Higher-order functions, currying, and closures provide powerful functional abstractions. Pure functions and immutability make business logic easier to test and reason about. Private properties and methods provide strong encapsulation when objects need to protect their internal state.
The real goal is not to become "an FP developer" or "an OOP developer."
The goal is to become a developer who can choose the right abstraction for the problem.
Use objects when ownership and state matter.
Use functions when transformation and predictability matter.
Use composition when you need flexibility.
Use immutability when predictable state transitions matter.
And combine the paradigms when the system benefits from both.

Top comments (0)