Programming Paradigms
Six fundamental approaches to writing software — explained in plain English, with real vocabulary, code examples, and guidance on when each one shines.
Object-Oriented Programming (OOP)
Model software as interacting objects that combine data and behaviour.
Core Concept
Real-world entities are represented as objects — each object bundles its data (fields) and the actions it can perform (methods). You design systems by defining classes (blueprints) and creating objects from them.
Key Vocabulary
- Class
- A blueprint or template describing what an object looks like and what it can do. Like architectural plans — the plans are not a building, but you build from them.
- Object (Instance)
- A single instance created from a class. If User is the class, the user Alice (with her specific email and name) is an object.
- Inheritance
- A child class extends a parent class and inherits its properties and methods. AdminUser extends User — it has everything User has, plus admin-specific permissions.
- Polymorphism
- Different objects respond to the same method call in their own way. Both Dog and Cat have a speak() method — Dog says "Woof", Cat says "Meow".
- Encapsulation
- Hiding internal data and only exposing a clean public interface. The engine of a car is hidden — you only interact with the steering wheel and pedals.
- Abstraction
- Hiding complex implementation details and showing only the essential features. A TV remote abstracts all the electronics — you just press the button.
📋 Code example
// TypeScript OOP example
class User {
private email: string;
protected name: string;
constructor(name: string, email: string) {
this.name = name;
this.email = email; // private — only readable via getter
}
greet(): string {
return `Hello, I'm ${this.name}`;
}
getEmail(): string { // encapsulation: controlled access
return this.email;
}
}
class AdminUser extends User { // inheritance
private permissions: string[];
constructor(name: string, email: string, permissions: string[]) {
super(name, email); // call parent constructor
this.permissions = permissions;
}
greet(): string { // polymorphism: overrides parent method
return `${super.greet()} (Admin)`;
}
}
const alice = new User('Alice', 'alice@example.com');
const bob = new AdminUser('Bob', 'bob@example.com', ['users:write']);
console.log(alice.greet()); // "Hello, I'm Alice"
console.log(bob.greet()); // "Hello, I'm Bob (Admin)" Functional Programming (FP)
Build programs by composing pure, stateless functions — no hidden side effects.
Core Concept
Functions are treated like values — you pass them around, compose them, and return them. The key rule: a function always returns the same output for the same input, and never modifies anything outside itself.
Key Vocabulary
- Pure Function
- A function with no side effects: given the same inputs, it always returns the same output and never modifies external state. add(2, 3) is always 5 — no matter when or how often you call it.
- Immutability
- Data is never modified after creation. Instead of changing an existing array, you return a new one. This eliminates a whole class of bugs caused by unexpected mutation.
- Higher-Order Function
- A function that takes another function as a parameter or returns one. map(), filter(), and reduce() are classic examples.
- map / filter / reduce
- map transforms every element; filter keeps only matching elements; reduce folds a list into a single value. The three fundamental FP array operations.
- Side Effects
- Anything a function does beyond returning a value: writing to a database, logging, modifying a variable. FP minimises side effects — they are isolated and explicit.
- Referential Transparency
- An expression can be replaced with its value without changing the program's behaviour. If getUser(1) always returns the same User, you can cache or reason about it freely.
📋 Code example
// Functional style in TypeScript — no mutations, no side effects
// Pure function: same input → always same output
const add = (a: number, b: number): number => a + b;
// Immutable transformation — returns NEW array, never mutates original
const prices = [10, 25, 8, 45, 15];
const discounted = prices
.filter(p => p >= 10) // keep items $10 or more
.map(p => p * 0.9) // apply 10% discount
.reduce((sum, p) => sum + p, 0); // total after discount
// 85.5 — computed without modifying 'prices'
// Higher-order function: takes a function, returns a function
const withLogging = <T, R>(fn: (arg: T) => R) =>
(arg: T): R => {
console.log(`Calling with: ${arg}`);
const result = fn(arg);
console.log(`Result: ${result}`);
return result;
};
const addTax = withLogging((price: number) => price * 1.2);
addTax(100); // logs inputs/outputs, returns 120 Declarative vs. Imperative
Declarative says "what you want". Imperative says "how to do it step by step".
Core Concept
These are two opposite styles of writing code. Imperative code describes every step of the algorithm. Declarative code describes the desired outcome and lets the runtime figure out the steps.
Key Vocabulary
- Imperative
- You write explicit instructions for the machine: "do this, then do this, then check this condition, then loop". The control flow is up to you.
- Declarative
- You describe the desired outcome: "give me all users where active = true". The system (SQL engine, React runtime) decides how to execute it.
- Abstraction Level
- Declarative code operates at a higher level of abstraction — you express intent, not mechanism. HTML says "this is a heading", not "draw pixels at these coordinates".
📋 Code example
// ── IMPERATIVE: how to do it ────────────────────────────────────
// Filter active users — you manage the loop and accumulator
const users = [
{ name: 'Alice', active: true },
{ name: 'Bob', active: false },
{ name: 'Carol', active: true },
];
const activeUsers = [];
for (let i = 0; i < users.length; i++) {
if (users[i].active) {
activeUsers.push(users[i]);
}
}
// ── DECLARATIVE: what you want ───────────────────────────────────
// Same result — you state the intent, filter handles the loop
const activeUsers2 = users.filter(u => u.active);
// ── DECLARATIVE: SQL ─────────────────────────────────────────────
// SELECT name FROM users WHERE active = true;
// You declare the result you want — the DB engine optimises the path
// ── DECLARATIVE: Terraform IaC ───────────────────────────────────
// resource "aws_s3_bucket" "my_bucket" {
// bucket = "my-app-assets"
// }
// You declare "I want this bucket to exist" — Terraform figures out how Procedural Programming
Organise code into reusable named procedures (functions) that execute step by step.
Core Concept
Procedural programming is the simplest paradigm: write a series of instructions that execute top to bottom, and group reusable logic into named procedures or functions. Older than OOP, still dominant in C, shell scripts, and many algorithms.
Key Vocabulary
- Procedure / Routine
- A named, reusable block of code that performs a task. In modern terms: a function without a return value (void) — like printReport() or saveToFile(path).
- Call Stack
- When procedure A calls procedure B which calls procedure C, they stack up. When C returns, control goes back to B, then to A. The call stack tracks this return order.
- Global State
- Variables accessible from any procedure. Procedural code often relies on shared global state, which is its biggest weakness — unexpected mutations are hard to trace.
- Modular Programming
- Splitting procedures into separate files/modules. An improvement over monolithic procedural code — functions are reusable across programs.
📋 Code example
// C-style procedural approach (TypeScript analogy)
// Each procedure does one thing
function readConfig(path: string): string {
return fs.readFileSync(path, 'utf-8');
}
function parseConfig(raw: string): Record<string, string> {
return Object.fromEntries(
raw.split('\n').map(line => line.split('='))
);
}
function startServer(config: Record<string, string>): void {
const port = parseInt(config['PORT'] ?? '3000');
console.log(`Server starting on port ${port}`);
// ...
}
// Main entry point: call procedures in order
function main(): void {
const raw = readConfig('./config.env');
const config = parseConfig(raw);
startServer(config);
}
main(); Event-Driven Programming
Code reacts to events — things that happen — rather than running top to bottom.
Core Concept
Instead of a linear flow, the program sits idle until an event occurs — a user click, an HTTP request arriving, a message appearing in a queue. Event handlers (callbacks/listeners) then respond to each event.
Key Vocabulary
- Event
- Something that happens that the program can respond to: a button click, an HTTP request, a file change, a timer firing, a message arriving in a queue.
- Event Listener / Handler
- A function registered to run when a specific event occurs. element.addEventListener('click', handleClick) registers handleClick to run on every click.
- Callback
- A function passed to another function to be called when an event completes. The classic pattern before promises and async/await.
- Event Loop
- JavaScript's mechanism for handling async events on a single thread. It continuously checks the event queue and runs callbacks when the main thread is free.
- Publish / Subscribe (Pub/Sub)
- An event pattern where publishers emit events without knowing who's listening, and subscribers react without knowing who published. Decouples components.
- Message Queue
- In backend event-driven systems (RabbitMQ, Kafka, SQS), events are persisted in a queue. Consumers process them when ready — enabling asynchronous, scalable processing.
📋 Code example
// Browser event-driven example
const button = document.getElementById('submit-btn');
// Register a listener — code runs only when the event fires
button?.addEventListener('click', (event: MouseEvent) => {
event.preventDefault();
console.log('Button clicked! Submitting form...');
submitForm();
});
// Node.js EventEmitter — backend pub/sub
import { EventEmitter } from 'events';
const orderBus = new EventEmitter();
// Subscribers — registered independently and decoupled from each other
orderBus.on('order:placed', (order) => sendConfirmationEmail(order));
orderBus.on('order:placed', (order) => reserveInventory(order));
orderBus.on('order:placed', (order) => notifyWarehouse(order));
// Publisher — emits the event, doesn't know who's listening
function placeOrder(order: Order) {
saveOrderToDatabase(order);
orderBus.emit('order:placed', order); // triggers all listeners
} Reactive Programming
Treat data as streams that change over time — and declaratively wire how changes propagate.
Core Concept
Reactive programming models data sources as observable streams. You define transformations on those streams, and any time the source emits a new value, the transformation pipeline automatically re-runs. Popularised by RxJS in JavaScript.
Key Vocabulary
- Observable
- A data source that emits values over time — like an async iterator. You subscribe to it and receive values as they arrive: HTTP responses, mouse moves, WebSocket messages.
- Stream
- A sequence of values emitted over time. A stream of click events, a stream of price updates, a stream of HTTP responses — all can be processed with the same operators.
- Subscription
- The act of "listening to" an observable. When you subscribe, you provide handlers for: next value, error, and completion. Don't forget to unsubscribe to avoid memory leaks.
- Operator
- A function that transforms a stream: map, filter, debounceTime, switchMap, mergeMap. Chain operators to build declarative data transformation pipelines.
- Backpressure
- When a producer emits values faster than the consumer can process them, backpressure mechanisms (buffering, dropping, windowing) prevent overwhelming the consumer.
- Subject
- Both an observable and an observer — it can receive values (next()) and emit them to all subscribers. Used as a bridge between non-reactive and reactive code.
📋 Code example
// RxJS example: autocomplete search with debounce
import { fromEvent, switchMap, debounceTime, distinctUntilChanged, map } from 'rxjs';
import { ajax } from 'rxjs/ajax';
const input = document.getElementById('search') as HTMLInputElement;
fromEvent(input, 'input') // Stream of keyboard events
.pipe(
map((e: Event) => (e.target as HTMLInputElement).value),
debounceTime(300), // wait 300ms after user stops typing
distinctUntilChanged(), // don't search if value didn't change
switchMap(query => // cancel previous HTTP request, start new one
ajax.getJSON(`/api/search?q=${query}`)
)
)
.subscribe({
next: results => renderResults(results),
error: err => showError(err),
});
// Without reactive: you'd manually manage timers, XHR cancellation,
// and deduplication — all with state variables and callbacks.