- Published on
Functional Programming Practical Guide: Immutability, Pure Functions, and Monads for Backend Developers
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction: Why Functional Programming in 2025
- 1. Immutability
- 2. Pure Functions
- 3. Higher-Order Functions
- 4. Closures and Currying
- 5. Algebraic Data Types (ADTs)
- 6. Option/Maybe and Result/Either
- 7. Monads Made Simple
- 8. Functor, Applicative, Monad Hierarchy
- 9. FP in Practice: TypeScript (fp-ts / Effect)
- 10. FP in Practice: Python
- 11. FP vs OOP
- 12. FP Patterns for Backend
- 13. Interview Questions (15)
- 14. Quiz (5 Questions)
- 15. References
Introduction: Why Functional Programming in 2025
In 2025, functional programming (FP) is no longer an academic curiosity. React Hooks made functional components the standard. Rust's ownership system enforces immutability at the language level. In the TypeScript ecosystem, fp-ts and Effect are growing rapidly, and Python keeps strengthening functools and pattern matching.
Why FP matters for backend developers:
- Concurrency safety: Immutable data eliminates race conditions at the root
- Testability: Pure functions can be tested without mocks
- Composability: Build complex business logic by combining small functions
- Error handling: Manage null and exceptions systematically with Option/Result types
┌──────────────────────────────────────────────────┐
│ Why FP Went Mainstream (2025) │
├──────────────────────────────────────────────────┤
│ React Hooks → Functional components standard │
│ Rust → Ownership + immutability │
│ TypeScript → fp-ts, Effect ecosystem growth │
│ Java 21+ → Records, Pattern Matching │
│ Python 3.12+ → match statement, functools │
│ Kotlin → Coroutines + FP built-in │
└──────────────────────────────────────────────────┘
This article explains core FP concepts with TypeScript, Python, and Scala code, including practical backend patterns.
1. Immutability
1.1 What Is Immutability
Immutability is the principle of never modifying data after creation. When changes are needed, create new data by copying the original.
// Mutable - dangerous
const user = { name: "Alice", age: 30 };
user.age = 31; // Original mutated → unexpected behavior for other references
// Immutable - safe
const user = { name: "Alice", age: 30 };
const updatedUser = { ...user, age: 31 }; // New object created
// user is still { name: "Alice", age: 30 }
1.2 Why Immutability Matters
┌──────────────────────────────────────┐
│ Problems with Mutable State │
├──────────────────────────────────────┤
│ Thread A: user.balance = 100 │
│ Thread B: user.balance = 200 │
│ Result: ??? (Race Condition) │
├──────────────────────────────────────┤
│ Immutable State Solves This │
├──────────────────────────────────────┤
│ Thread A: newUser = {...user, 100} │
│ Thread B: newUser = {...user, 200} │
│ Result: Each independent (safe) │
└──────────────────────────────────────┘
1.3 Language-Level Immutability Support
TypeScript:
// readonly keyword
interface User {
readonly name: string;
readonly age: number;
}
// as const
const CONFIG = {
port: 3000,
host: "localhost",
} as const;
// Readonly utility type
type ImmutableUser = Readonly<User>;
// Deep immutability (DeepReadonly)
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};
Python:
from dataclasses import dataclass
from typing import NamedTuple
# frozen dataclass
@dataclass(frozen=True)
class User:
name: str
age: int
user = User(name="Alice", age=30)
# user.age = 31 # FrozenInstanceError!
# Update using replace
from dataclasses import replace
updated = replace(user, age=31)
# NamedTuple is also immutable
class Point(NamedTuple):
x: float
y: float
Scala:
// case class is immutable by default
case class User(name: String, age: Int)
val user = User("Alice", 30)
val updated = user.copy(age = 31)
// Immutable collections are the default
val list = List(1, 2, 3)
val newList = 0 :: list // List(0, 1, 2, 3) — original preserved
1.4 Structural Sharing
If immutable data required full copies every time, performance would suffer. Structural sharing solves this by sharing unchanged parts.
Original tree: After update:
A A'
/ \ / \
B C B C'
/ \ \ / \ \
D E F D E F'
→ B, D, E are shared (no copy needed)
→ Only A→A', C→C', F→F' newly created
In JavaScript, Immer and Immutable.js provide this:
import { produce } from "immer";
const state = {
users: [
{ name: "Alice", score: 100 },
{ name: "Bob", score: 200 },
],
};
const newState = produce(state, (draft) => {
draft.users[0].score = 150;
});
// state.users[1] === newState.users[1] → true (structural sharing)
2. Pure Functions
2.1 Definition of Pure Functions
A pure function satisfies two conditions:
- Deterministic: Same input always produces the same output
- No side effects: Does not modify external state
// Pure function
function add(a: number, b: number): number {
return a + b;
}
// Impure - depends on external state
let counter = 0;
function increment(): number {
return ++counter; // Side effect: modifies external variable
}
// Impure - non-deterministic
function now(): number {
return Date.now(); // Different result every time
}
2.2 Referential Transparency
The property that an expression can be replaced with its result without changing program behavior.
// Referentially transparent
const x = add(2, 3); // 5
// add(2, 3) can be replaced with 5 anywhere
// Referentially opaque
const y = Math.random();
// Replacing Math.random() with y's value changes behavior
2.3 Side Effect Isolation Strategy
In practice, side effects cannot be completely avoided. The key is separating pure parts from impure parts.
// Bad: side effects mixed into business logic
async function processOrder(orderId: string) {
const order = await db.findOrder(orderId); // side effect
const discount = calculateDiscount(order); // pure
const total = applyDiscount(order.amount, discount); // pure
await db.updateOrder(orderId, total); // side effect
await emailService.send(order.email, total); // side effect
}
// Good: pure logic separated
// Pure functions
function calculateDiscount(order: Order): number {
if (order.items.length > 10) return 0.1;
if (order.totalAmount > 100000) return 0.05;
return 0;
}
function applyDiscount(amount: number, rate: number): number {
return amount * (1 - rate);
}
function computeOrderResult(order: Order): OrderResult {
const discount = calculateDiscount(order);
const total = applyDiscount(order.amount, discount);
return { orderId: order.id, total, discount };
}
// Impure shell (kept thin)
async function processOrder(orderId: string) {
const order = await db.findOrder(orderId);
const result = computeOrderResult(order); // Pure logic call
await db.updateOrder(result.orderId, result.total);
await emailService.send(order.email, result.total);
}
┌──────────────────────────────────────────────┐
│ Functional Core / Imperative Shell │
├──────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────┐ │
│ │ Imperative Shell │ │
│ │ (DB, Network, File I/O) │ │
│ │ ┌────────────────────────┐ │ │
│ │ │ Functional Core │ │ │
│ │ │ (Pure Business Logic) │ │ │
│ │ │ - Calculations │ │ │
│ │ │ - Transformations │ │ │
│ │ │ - Validations │ │ │
│ │ └────────────────────────┘ │ │
│ └────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────┘
3. Higher-Order Functions
3.1 What Are Higher-Order Functions
Functions that take functions as arguments or return functions.
// Takes a function as argument
function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
const result: U[] = [];
for (const item of arr) {
result.push(fn(item));
}
return result;
}
// Returns a function
function multiplier(factor: number): (n: number) => number {
return (n: number) => n * factor;
}
const double = multiplier(2);
const triple = multiplier(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
3.2 map, filter, reduce
const orders = [
{ id: 1, amount: 50000, status: "completed" },
{ id: 2, amount: 30000, status: "pending" },
{ id: 3, amount: 80000, status: "completed" },
{ id: 4, amount: 20000, status: "cancelled" },
];
// Imperative approach
let total = 0;
for (const order of orders) {
if (order.status === "completed") {
total += order.amount;
}
}
// Functional approach
const totalFP = orders
.filter((o) => o.status === "completed")
.map((o) => o.amount)
.reduce((sum, amt) => sum + amt, 0);
// 130000
Python:
from functools import reduce
orders = [
{"id": 1, "amount": 50000, "status": "completed"},
{"id": 2, "amount": 30000, "status": "pending"},
{"id": 3, "amount": 80000, "status": "completed"},
{"id": 4, "amount": 20000, "status": "cancelled"},
]
total = reduce(
lambda acc, o: acc + o["amount"],
filter(lambda o: o["status"] == "completed", orders),
0
)
# 130000
3.3 Function Composition
Combine small functions to create complex transformations.
// Manual composition
const compose = <A, B, C>(
f: (b: B) => C,
g: (a: A) => B
): ((a: A) => C) => (a: A) => f(g(a));
const pipe = <A, B, C>(
f: (a: A) => B,
g: (b: B) => C
): ((a: A) => C) => (a: A) => g(f(a));
// Usage
const trim = (s: string) => s.trim();
const toLowerCase = (s: string) => s.toLowerCase();
const split = (sep: string) => (s: string) => s.split(sep);
const tokenize = pipe(
trim,
pipe(toLowerCase, split(" "))
);
tokenize(" Hello World "); // ["hello", "world"]
3.4 Point-Free Style
A style where function arguments are not explicitly mentioned.
// Explicit style
const getNames = (users: User[]) => users.map((u) => u.name);
// Point-free style
const getName = (u: User) => u.name;
const getNames = (users: User[]) => users.map(getName);
// More extreme point-free (fp-ts style)
import { pipe } from "fp-ts/function";
import * as A from "fp-ts/Array";
const getNames = pipe(
A.map(getName)
);
4. Closures and Currying
4.1 Closures
A closure is when a function remembers the lexical environment where it was declared.
function createCounter(initial: number = 0) {
let count = initial;
return {
increment: () => ++count,
decrement: () => --count,
getCount: () => count,
};
}
const counter = createCounter(10);
counter.increment(); // 11
counter.increment(); // 12
counter.getCount(); // 12
Practical use: Config factories
function createLogger(prefix: string, level: string) {
return (message: string) => {
console.log(`[${prefix}] [${level}] ${message}`);
};
}
const infoLogger = createLogger("MyApp", "INFO");
const errorLogger = createLogger("MyApp", "ERROR");
infoLogger("Server started"); // [MyApp] [INFO] Server started
errorLogger("DB connection failed"); // [MyApp] [ERROR] DB connection failed
4.2 Currying
Transforming a multi-argument function into a chain of single-argument functions.
// Normal function
function add(a: number, b: number): number {
return a + b;
}
// Curried function
function curriedAdd(a: number): (b: number) => number {
return (b: number) => a + b;
}
const add5 = curriedAdd(5);
add5(3); // 8
add5(10); // 15
// Generic curry utility
function curry<A, B, C>(fn: (a: A, b: B) => C): (a: A) => (b: B) => C {
return (a: A) => (b: B) => fn(a, b);
}
4.3 Partial Application
Similar to currying but allows fixing multiple arguments at once.
// Practical example: API client factory
function createApiClient(baseUrl: string, headers: Record<string, string>) {
return async function request(method: string, path: string, body?: unknown) {
const response = await fetch(`${baseUrl}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
return response.json();
};
}
const api = createApiClient("https://api.example.com", {
Authorization: "Bearer token123",
"Content-Type": "application/json",
});
// Simple usage afterwards
await api("GET", "/users");
await api("POST", "/orders", { item: "book", qty: 1 });
4.4 Middleware Pattern
type Middleware<T> = (data: T) => T;
function compose<T>(...middlewares: Middleware<T>[]): Middleware<T> {
return (data: T) => middlewares.reduce((acc, mw) => mw(acc), data);
}
// Request processing middleware
const addTimestamp = (req: Request) => ({
...req,
timestamp: Date.now(),
});
const addRequestId = (req: Request) => ({
...req,
requestId: crypto.randomUUID(),
});
const validateAuth = (req: Request) => {
if (!req.headers.authorization) throw new Error("Unauthorized");
return req;
};
const pipeline = compose(addTimestamp, addRequestId, validateAuth);
5. Algebraic Data Types (ADTs)
5.1 Sum Types (Union Types)
A type that can be one of several possible forms. "A or B or C."
// TypeScript: Discriminated Union
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
}
}
Python (3.10+ match):
from dataclasses import dataclass
from math import pi
@dataclass
class Circle:
radius: float
@dataclass
class Rectangle:
width: float
height: float
@dataclass
class Triangle:
base: float
height: float
Shape = Circle | Rectangle | Triangle
def area(shape: Shape) -> float:
match shape:
case Circle(radius=r):
return pi * r ** 2
case Rectangle(width=w, height=h):
return w * h
case Triangle(base=b, height=h):
return b * h / 2
Scala:
enum Shape:
case Circle(radius: Double)
case Rectangle(width: Double, height: Double)
case Triangle(base: Double, height: Double)
def area(shape: Shape): Double = shape match
case Shape.Circle(r) => Math.PI * r * r
case Shape.Rectangle(w, h) => w * h
case Shape.Triangle(b, h) => b * h / 2
5.2 Product Types
A type that holds multiple values simultaneously. "A and B and C."
// Tuple
type Coordinate = [number, number];
// Record / Interface
interface User {
name: string;
email: string;
age: number;
}
5.3 State Modeling with ADTs
// Bad: boolean flag proliferation
interface RequestState {
isLoading: boolean;
isError: boolean;
data: Data | null;
error: Error | null;
}
// isLoading=true, isError=true, data=something → impossible state representable
// Good: ADT eliminates impossible states
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function renderState<T>(state: RequestState<T>): string {
switch (state.status) {
case "idle": return "Idle";
case "loading": return "Loading...";
case "success": return `Data: ${state.data}`;
case "error": return `Error: ${state.error.message}`;
}
}
6. Option/Maybe and Result/Either
6.1 The Problem with Null: The Billion Dollar Mistake
Tony Hoare called null references his "billion-dollar mistake." The Option type expresses that a value may or may not exist at the type level.
// Option type implementation
type Option<T> = { tag: "some"; value: T } | { tag: "none" };
const some = <T>(value: T): Option<T> => ({ tag: "some", value });
const none: Option<never> = { tag: "none" };
function map<T, U>(opt: Option<T>, fn: (v: T) => U): Option<U> {
return opt.tag === "some" ? some(fn(opt.value)) : none;
}
function flatMap<T, U>(opt: Option<T>, fn: (v: T) => Option<U>): Option<U> {
return opt.tag === "some" ? fn(opt.value) : none;
}
function getOrElse<T>(opt: Option<T>, defaultValue: T): T {
return opt.tag === "some" ? opt.value : defaultValue;
}
6.2 Practical: Safe Data Access
// Dangerous code
function getUserCity(userId: string): string {
const user = db.findUser(userId); // could be null
const address = user.address; // error if null!
return address.city; // error if null!
}
// Safe with Option
function getUserCity(userId: string): Option<string> {
return pipe(
findUser(userId), // Option<User>
flatMap((user) => getAddress(user)), // Option<Address>
map((address) => address.city) // Option<string>
);
}
// Usage
const city = getOrElse(getUserCity("u1"), "Unknown");
6.3 Result/Either: Error Handling Without Exceptions
// Result type implementation
type Result<T, E> =
| { tag: "ok"; value: T }
| { tag: "err"; error: E };
const ok = <T>(value: T): Result<T, never> => ({ tag: "ok", value });
const err = <E>(error: E): Result<never, E> => ({ tag: "err", error });
function mapResult<T, U, E>(
result: Result<T, E>,
fn: (v: T) => U
): Result<U, E> {
return result.tag === "ok" ? ok(fn(result.value)) : result;
}
function flatMapResult<T, U, E>(
result: Result<T, E>,
fn: (v: T) => Result<U, E>
): Result<U, E> {
return result.tag === "ok" ? fn(result.value) : result;
}
6.4 Practical: Validation Chain
type ValidationError =
| { code: "EMPTY_NAME"; message: string }
| { code: "INVALID_EMAIL"; message: string }
| { code: "UNDERAGE"; message: string };
function validateName(name: string): Result<string, ValidationError> {
return name.trim().length > 0
? ok(name.trim())
: err({ code: "EMPTY_NAME", message: "Name is required" });
}
function validateEmail(email: string): Result<string, ValidationError> {
return email.includes("@")
? ok(email)
: err({ code: "INVALID_EMAIL", message: "Invalid email" });
}
function validateAge(age: number): Result<number, ValidationError> {
return age >= 18
? ok(age)
: err({ code: "UNDERAGE", message: "Must be 18 or older" });
}
// Chaining
function validateUser(input: UserInput): Result<ValidUser, ValidationError> {
const nameResult = validateName(input.name);
if (nameResult.tag === "err") return nameResult;
const emailResult = validateEmail(input.email);
if (emailResult.tag === "err") return emailResult;
const ageResult = validateAge(input.age);
if (ageResult.tag === "err") return ageResult;
return ok({
name: nameResult.value,
email: emailResult.value,
age: ageResult.value,
});
}
7. Monads Made Simple
7.1 What Is a Monad
A monad is a type with a context (a box) that wraps a value and provides an interface for chaining operations.
Three components:
- Type constructor: The context wrapping a value (
Option<T>,Result<T, E>,Promise<T>) - of (return/unit): Puts a value into the context (
some(5),ok(5),Promise.resolve(5)) - flatMap (bind/chain): Applies a function to the value inside the context, flattening nested contexts
Regular map: Option<T> → (T → U) → Option<U>
flatMap: Option<T> → (T → Option<U>) → Option<U>
map transforms the value inside the box
flatMap creates a new box from the value and unwraps the double box
7.2 The Box Metaphor
┌──────────────────────────────────────────────────┐
│ map: Open box, transform, put back │
│ │
│ [5] --map(x => x * 2)--> [10] │
│ │
│ flatMap: Open box, create new box │
│ │
│ [5] --flatMap(x => [x, x+1])--> [5, 6] │
│ (NOT double-boxed [[5, 6]]!) │
│ │
│ Option example: │
│ Some(5) --flatMap(x => Some(x*2))--> Some(10) │
│ None --flatMap(x => Some(x*2))--> None │
│ (If None, function execution is skipped) │
└──────────────────────────────────────────────────┘
7.3 Monad Laws
// 1. Left Identity
// of(a).flatMap(f) === f(a)
flatMap(some(5), double) === double(5)
// 2. Right Identity
// m.flatMap(of) === m
flatMap(some(5), some) === some(5)
// 3. Associativity
// m.flatMap(f).flatMap(g) === m.flatMap(x => f(x).flatMap(g))
7.4 Is Promise a Monad?
// Promise.resolve = of
const p = Promise.resolve(5);
// .then = flatMap (actually serves both map and flatMap roles)
p.then((x) => x * 2); // like map
p.then((x) => Promise.resolve(x * 2)); // like flatMap (auto-flatten)
// Promise is "almost" a monad
// But auto-flattening causes slight deviations from strict monad laws
// Promise<Promise<T>> is impossible → always flattened to Promise<T>
7.5 Practical: Async Pipeline
async function processUserOrder(userId: string, itemId: string) {
return pipe(
await findUser(userId), // Result<User, Error>
flatMapResult((user) => validateCredit(user)), // Result<User, Error>
flatMapResult((user) =>
pipe(
findItem(itemId), // Result<Item, Error>
mapResult((item) => ({ user, item }))
)
),
flatMapResult(({ user, item }) =>
createOrder(user, item) // Result<Order, Error>
),
);
}
8. Functor, Applicative, Monad Hierarchy
8.1 Hierarchy Diagram
┌───────────────────────────────────────┐
│ Monad │
│ flatMap: F<A> → (A → F<B>) → F<B> │
│ │
│ ┌───────────────────────────────┐ │
│ │ Applicative │ │
│ │ ap: F<A→B> → F<A> → F<B> │ │
│ │ │ │
│ │ ┌───────────────────────┐ │ │
│ │ │ Functor │ │ │
│ │ │ map: F<A> → (A→B) │ │ │
│ │ │ → F<B> │ │ │
│ │ └───────────────────────┘ │ │
│ └───────────────────────────────┘ │
└───────────────────────────────────────┘
8.2 Functor
"Something that can be mapped over" — transforms the value inside a context.
// Functor interface
interface Functor<F> {
map: <A, B>(fa: F<A>, f: (a: A) => B) => F<B>;
}
// Array is a Functor
[1, 2, 3].map(x => x * 2) // [2, 4, 6]
// Option is a Functor
map(some(5), x => x * 2) // some(10)
map(none, x => x * 2) // none
// Promise is a Functor
Promise.resolve(5).then(x => x * 2) // Promise<10>
8.3 Applicative
Applies a function to values in multiple contexts.
// When you want to add two Option values
const a: Option<number> = some(3);
const b: Option<number> = some(4);
// map alone is insufficient:
// map(a, x => map(b, y => x + y)) → Option<Option<number>> nested!
// Applicative: puts function in context and applies
function ap<A, B>(
ff: Option<(a: A) => B>,
fa: Option<A>
): Option<B> {
if (ff.tag === "none" || fa.tag === "none") return none;
return some(ff.value(fa.value));
}
const add = (x: number) => (y: number) => x + y;
const result = ap(map(a, add), b); // some(7)
8.4 Practical Implications
Functor: Transform a single value → map
Applicative: Combine independent values → parallel validation
Monad: Sequential dependent ops → next step depends on previous result
Practical examples:
- Functor: Convert username to uppercase
- Applicative: Validate name, email, age simultaneously and combine results
- Monad: Look up user → query user's orders → process order payment
9. FP in Practice: TypeScript (fp-ts / Effect)
9.1 fp-ts Basics
import { pipe, flow } from "fp-ts/function";
import * as O from "fp-ts/Option";
import * as E from "fp-ts/Either";
import * as A from "fp-ts/Array";
import * as TE from "fp-ts/TaskEither";
// Option usage
const findUser = (id: string): O.Option<User> =>
users.has(id) ? O.some(users.get(id)!) : O.none;
const getUserEmail = (id: string): string =>
pipe(
findUser(id),
O.map((u) => u.email),
O.getOrElse(() => "unknown@example.com")
);
// Either usage
const parseAge = (input: string): E.Either<string, number> => {
const age = parseInt(input, 10);
return isNaN(age)
? E.left("Invalid age")
: age < 0
? E.left("Age cannot be negative")
: E.right(age);
};
// Chaining with pipe
const result = pipe(
"25",
parseAge,
E.map((age) => age + 1),
E.fold(
(error) => `Error: ${error}`,
(age) => `Next year's age: ${age}`
)
);
9.2 TaskEither: Async Error Handling
import * as TE from "fp-ts/TaskEither";
// Wrap DB query as TaskEither
const findUserTE = (id: string): TE.TaskEither<Error, User> =>
TE.tryCatch(
() => db.findUser(id),
(reason) => new Error(`User not found: ${reason}`)
);
const findOrderTE = (userId: string): TE.TaskEither<Error, Order[]> =>
TE.tryCatch(
() => db.findOrders(userId),
(reason) => new Error(`Orders not found: ${reason}`)
);
// Pipeline
const getUserOrders = (userId: string) =>
pipe(
findUserTE(userId),
TE.chain((user) => findOrderTE(user.id)),
TE.map((orders) => orders.filter((o) => o.status === "active")),
TE.fold(
(error) => async () => ({ error: error.message }),
(orders) => async () => ({ data: orders })
)
);
9.3 Effect Library
Effect is the next generation of fp-ts, a more practical and powerful FP framework.
import { Effect, pipe } from "effect";
// Effect definition
const divide = (a: number, b: number): Effect.Effect<number, Error> =>
b === 0
? Effect.fail(new Error("Division by zero"))
: Effect.succeed(a / b);
// Pipeline
const program = pipe(
divide(10, 2),
Effect.flatMap((result) => divide(result, 3)),
Effect.map((result) => `Result: ${result}`),
Effect.catchAll((error) =>
Effect.succeed(`Error: ${error.message}`)
)
);
// Execute
Effect.runPromise(program).then(console.log);
9.4 Branded Types
// Give meaning to primitive types
type UserId = string & { readonly _brand: "UserId" };
type OrderId = string & { readonly _brand: "OrderId" };
const userId = (id: string): UserId => id as UserId;
const orderId = (id: string): OrderId => id as OrderId;
// Type safety: userId and orderId cannot be mixed up
function findOrder(id: OrderId): Order { /* ... */ }
// findOrder(userId("u1")) // Type error!
findOrder(orderId("o1")); // OK
10. FP in Practice: Python
10.1 functools Usage
from functools import reduce, partial, lru_cache
from typing import Callable, TypeVar
T = TypeVar("T")
# partial: partial application
def power(base: int, exponent: int) -> int:
return base ** exponent
square = partial(power, exponent=2)
cube = partial(power, exponent=3)
print(square(5)) # 25
print(cube(3)) # 27
# reduce: accumulation
numbers = [1, 2, 3, 4, 5]
total = reduce(lambda acc, x: acc + x, numbers, 0) # 15
product = reduce(lambda acc, x: acc * x, numbers, 1) # 120
# lru_cache: memoization (ideal for pure functions)
@lru_cache(maxsize=128)
def fibonacci(n: int) -> int:
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
10.2 itertools Usage
from itertools import chain, groupby, islice, starmap, accumulate
# chain: connect iterables
combined = list(chain([1, 2], [3, 4], [5, 6])) # [1, 2, 3, 4, 5, 6]
# groupby: grouping
data = [
{"dept": "eng", "name": "Alice"},
{"dept": "eng", "name": "Bob"},
{"dept": "sales", "name": "Charlie"},
]
sorted_data = sorted(data, key=lambda x: x["dept"])
for key, group in groupby(sorted_data, key=lambda x: x["dept"]):
print(f"{key}: {[g['name'] for g in group]}")
# accumulate: running totals
running_total = list(accumulate([1, 2, 3, 4, 5])) # [1, 3, 6, 10, 15]
10.3 returns Library
from returns.result import Result, Success, Failure
from returns.maybe import Maybe, Some, Nothing
from returns.pipeline import flow
from returns.pointfree import bind
# Maybe usage
def find_user(user_id: str) -> Maybe[dict]:
users = {"u1": {"name": "Alice", "email": "alice@example.com"}}
return Some(users[user_id]) if user_id in users else Nothing
def get_email(user: dict) -> Maybe[str]:
email = user.get("email")
return Some(email) if email else Nothing
# Chaining
result = find_user("u1").bind(get_email) # Some("alice@example.com")
result = find_user("u999").bind(get_email) # Nothing
# Result usage
def parse_int(value: str) -> Result[int, str]:
try:
return Success(int(value))
except ValueError:
return Failure(f"Cannot parse '{value}' as int")
def validate_positive(n: int) -> Result[int, str]:
return Success(n) if n > 0 else Failure("Must be positive")
# Pipeline
result = parse_int("42").bind(validate_positive) # Success(42)
result = parse_int("-1").bind(validate_positive) # Failure("Must be positive")
result = parse_int("abc").bind(validate_positive) # Failure("Cannot parse 'abc' as int")
10.4 frozen dataclass Pattern
from dataclasses import dataclass, field, replace
from typing import Tuple
@dataclass(frozen=True)
class AppState:
users: Tuple[str, ...] = ()
counter: int = 0
def add_user(self, name: str) -> "AppState":
return replace(self, users=self.users + (name,))
def increment(self) -> "AppState":
return replace(self, counter=self.counter + 1)
# Immutable state transformation chain
state = AppState()
state = state.add_user("Alice").add_user("Bob").increment()
print(state) # AppState(users=('Alice', 'Bob'), counter=1)
11. FP vs OOP
11.1 Comparison Table
┌──────────────────┬──────────────────────┬──────────────────────┐
│ Aspect │ OOP │ FP │
├──────────────────┼──────────────────────┼──────────────────────┤
│ Basic unit │ Object (state+behav) │ Function (transform) │
│ State mgmt │ Mutable encapsulate │ Immutable transform │
│ Polymorphism │ Inheritance/Iface │ Type class/Matching │
│ Code reuse │ Inheritance │ Composition │
│ Side effects │ Anywhere allowed │ Isolated/managed │
│ Concurrency │ Locks/sync needed │ Safe via immutability│
│ Error handling │ Exceptions try-catch │ Result / Either │
│ Design patterns │ GoF patterns │ Monads, functors │
│ Testing │ Mock/DI required │ Pure function direct │
│ Learning curve │ Relatively lower │ Relatively higher │
└──────────────────┴──────────────────────┴──────────────────────┘
11.2 When to Choose FP
FP excels when:
- Data transformation pipelines (ETL, stream processing)
- Concurrency-critical systems
- Complex business domain logic
- High test coverage requirements
- Immutability matters (finance, healthcare)
OOP excels when:
- GUI frameworks, game development (frequent state changes)
- Integration with legacy systems
- Team is familiar with OOP
- Framework assumes OOP design
11.3 Hybrid Approach
In modern practice, a hybrid approach is most effective rather than pure FP or pure OOP.
// Class + immutability + functional methods
class OrderService {
constructor(
private readonly db: Database,
private readonly logger: Logger
) {}
// Pure function: business logic
private static calculateTotal(items: ReadonlyArray<OrderItem>): number {
return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}
private static applyDiscount(
total: number,
discountRate: number
): number {
return total * (1 - discountRate);
}
// Impure shell: I/O
async processOrder(orderId: string): Promise<Result<Order, Error>> {
const order = await this.db.findOrder(orderId);
if (!order) return err(new Error("Order not found"));
const total = OrderService.calculateTotal(order.items);
const finalTotal = OrderService.applyDiscount(total, order.discountRate);
const updated = { ...order, total: finalTotal, status: "processed" as const };
await this.db.saveOrder(updated);
this.logger.info(`Order ${orderId} processed: ${finalTotal}`);
return ok(updated);
}
}
12. FP Patterns for Backend
12.1 Railway Oriented Programming
An error handling pattern that visualizes success/failure paths as two rails.
Success rail: ──── validate ──── transform ──── save ──── OK
│ │ │
Failure rail: ────────┼────────────────┼───────────┼──── ERR
│ │ │
Validation fail Transform err Save fail
// Railway function: success continues, failure stays on error rail
type Railway<T, E> = Result<T, E>;
function railway<A, B, E>(
fn: (a: A) => Railway<B, E>
): (input: Railway<A, E>) => Railway<B, E> {
return (input) => {
if (input.tag === "err") return input;
return fn(input.value);
};
}
// Practical: user registration pipeline
const registerUser = flow(
validateInput, // Railway<Input, ValidationError>
railway(checkDuplicate), // Railway<Input, DuplicateError>
railway(hashPassword), // Railway<HashedInput, HashError>
railway(saveToDb), // Railway<User, DbError>
railway(sendWelcomeEmail), // Railway<User, EmailError>
);
const result = registerUser(ok(rawInput));
12.2 Pipeline Pattern
// Data processing pipeline
type Transform<A, B> = (input: A) => B;
function pipeline<A>(...transforms: Transform<any, any>[]): Transform<A, any> {
return (input: A) => transforms.reduce((acc, fn) => fn(acc), input as any);
}
// ETL pipeline example
const processLogs = pipeline<RawLog[]>(
// Extract
(logs) => logs.filter((l) => l.level === "ERROR"),
// Transform
(logs) => logs.map((l) => ({
timestamp: new Date(l.ts),
message: l.msg,
service: l.service,
stackTrace: l.stack?.split("\n"),
})),
// Load
(logs) => groupBy(logs, (l) => l.service),
);
12.3 Event Sourcing and FP
Event sourcing is inherently a functional pattern: fold(events, initialState) = currentState
// Event definitions
type AccountEvent =
| { type: "ACCOUNT_OPENED"; owner: string; initialBalance: number }
| { type: "DEPOSITED"; amount: number }
| { type: "WITHDRAWN"; amount: number }
| { type: "CLOSED" };
// State definition
interface AccountState {
owner: string;
balance: number;
status: "active" | "closed";
}
const initialState: AccountState = {
owner: "",
balance: 0,
status: "active",
};
// Reducer: (State, Event) => State (pure function!)
function accountReducer(
state: AccountState,
event: AccountEvent
): AccountState {
switch (event.type) {
case "ACCOUNT_OPENED":
return {
owner: event.owner,
balance: event.initialBalance,
status: "active",
};
case "DEPOSITED":
return { ...state, balance: state.balance + event.amount };
case "WITHDRAWN":
return { ...state, balance: state.balance - event.amount };
case "CLOSED":
return { ...state, status: "closed" };
}
}
// Replay events to restore current state
const events: AccountEvent[] = [
{ type: "ACCOUNT_OPENED", owner: "Alice", initialBalance: 1000 },
{ type: "DEPOSITED", amount: 500 },
{ type: "WITHDRAWN", amount: 200 },
];
const currentState = events.reduce(accountReducer, initialState);
// { owner: "Alice", balance: 1300, status: "active" }
12.4 Immutable State Management (Redux Pattern)
// Action types (ADT)
type Action =
| { type: "ADD_TODO"; payload: { id: string; text: string } }
| { type: "TOGGLE_TODO"; payload: { id: string } }
| { type: "DELETE_TODO"; payload: { id: string } };
interface State {
readonly todos: ReadonlyArray<Todo>;
}
// Reducer: pure function
function reducer(state: State, action: Action): State {
switch (action.type) {
case "ADD_TODO":
return {
...state,
todos: [...state.todos, {
id: action.payload.id,
text: action.payload.text,
completed: false,
}],
};
case "TOGGLE_TODO":
return {
...state,
todos: state.todos.map((todo) =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed }
: todo
),
};
case "DELETE_TODO":
return {
...state,
todos: state.todos.filter((todo) => todo.id !== action.payload.id),
};
}
}
13. Interview Questions (15)
Basics (1-5)
Q1. What is a pure function and why is it important?
Show Answer
A pure function (1) always returns the same output for the same input and (2) has no side effects. It matters because it is easy to test and reason about, guarantees referential transparency, and enables function composition and optimizations like memoization.
Q2. Why does immutability help with concurrent programming?
Show Answer
Immutable data is safe to read from multiple threads simultaneously. Since data never changes, race conditions cannot occur and locks are unnecessary. Each thread creates its own new copy, so no thread affects another.
Q3. Explain the difference between map and flatMap.
Show Answer
map applies a function to transform the value inside a container: F(A) -> (A -> B) -> F(B). flatMap handles cases where the function returns a container, flattening the nesting: F(A) -> (A -> F(B)) -> F(B). For example, it returns Option(5) instead of Option(Option(5)).
Q4. Give 3 examples of higher-order functions.
Show Answer
(1) Array.map - takes a function as argument and applies it to each element. (2) Array.filter - takes a predicate function and returns elements where it is true. (3) Array.sort(compareFn) - takes a comparison function for sorting. Functions returning functions include currying and factory patterns.
Q5. What is the relationship between closures and pure functions?
Show Answer
A closure is a function that captures outer variables. If the captured variables are immutable, the closure can be a pure function. However, if captured variables are mutable, side effects emerge and it is no longer pure. In currying and partial application, closures capture configuration values to create pure functions.
Intermediate (6-10)
Q6. How would you explain monads to a non-developer?
Show Answer
A monad is like a "special box for values." This box lets you (1) put a value in (of), (2) transform the value inside (map), and (3) when transformation produces another box, it unwraps the double box (flatMap). For example, Promise is a "box for future values," and .then() transforms values while automatically unwrapping nested Promises.
Q7. Why is Option/Maybe better than null?
Show Answer
(1) Forces handling of null possibility at compile time - forgetting causes a type error. (2) Enables chaining that eliminates nested null checks. (3) Explicitly expresses "value might not exist" at the type level. (4) Automatic null propagation through map/flatMap.
Q8. Explain Railway Oriented Programming.
Show Answer
A pattern that visualizes error handling as two rails (success/failure). Each function returns Result/Either: on success it passes to the next function, on failure it immediately switches to the error rail. It eliminates try-catch blocks and handles error propagation through function composition. Proposed by Scott Wlaschin.
Q9. How does event sourcing connect to functional programming?
Show Answer
The core of event sourcing is fold(events, initialState) = currentState, where a pure reducer function sequentially applies an event stream to initial state to derive current state. State is immutable, and replaying events guarantees identical results. This is a direct application of FP's immutability, pure functions, and reduce pattern.
Q10. What is the difference between currying and partial application?
Show Answer
Currying transforms an n-argument function into a chain of n single-argument functions: f(a, b, c) becomes f(a)(b)(c). Partial application fixes some arguments to create a new function taking the remaining ones: from f(a, b, c), fixing a produces g(b, c). Curried functions naturally support partial application.
Advanced (11-15)
Q11. What is the role of TaskEither in fp-ts?
Show Answer
TaskEither represents "an async operation that can fail" as a type. TaskEither(E, A) equals () => Promise(Either(E, A)). It provides type-safe error handling instead of try-catch, and chains async pipelines with chain/map/fold. Ideal for fallible I/O operations like DB queries and API calls.
Q12. Explain the differences between Functor, Applicative, and Monad.
Show Answer
Functor has map (transform value in context). Applicative applies a function to values in multiple contexts (ap: combine parallel independent operations). Monad determines the next operation based on previous results (flatMap: sequential dependent operations). Hierarchically, Monad is the most powerful; every Monad is an Applicative and every Applicative is a Functor.
Q13. How do you solve performance problems with immutable data structures?
Show Answer
(1) Structural Sharing: only create changed parts, share references for the rest. (2) Persistent Data Structures: Trie-based O(log32 N) updates. (3) COW (Copy-on-Write): copy only when actually modified, like Immer. (4) Transient: mutable during construction, immutable after completion, like a builder pattern.
Q14. How do ADTs eliminate impossible states?
Show Answer
Instead of boolean flag combinations, use Discriminated Unions to specify only possible states. For example, instead of isLoading, isError, data, error as four fields, define four states: Idle, Loading, Success(data), Error(error). This blocks impossible states like "loading and error simultaneously" at the type level. This is the "Make illegal states unrepresentable" principle.
Q15. How do you gradually introduce FP into an existing OOP codebase?
Show Answer
(1) Start writing utilities/helpers as pure functions. (2) Introduce immutable data patterns (readonly, Object.freeze, Immer). (3) Separate business logic using the Functional Core / Imperative Shell pattern. (4) Improve error handling with Result/Option types. (5) Organize data transformation flows with the pipeline pattern. Gradual adoption rather than full conversion is key.
14. Quiz (5 Questions)
Quiz 1
Which of the following is NOT a pure function?
A) const add = (a, b) => a + b
B) const now = () => Date.now()
C) const double = (x) => x * 2
D) const head = (arr) => arr[0]
Show Answer
B) Date.now() returns a different value each time it is called, making it non-deterministic and therefore not a pure function.
Quiz 2
What is the output of this code?
const arr = [1, 2, 3, 4, 5];
const result = arr
.filter(x => x % 2 === 0)
.map(x => x * 3)
.reduce((acc, x) => acc + x, 0);
A) 15 B) 18 C) 30 D) 6
Show Answer
B) 18. filter produces [2, 4], map produces [6, 12], reduce yields 6 + 12 = 18.
Quiz 3
Which is the Left Identity monad law?
A) m.flatMap(of) === m
B) of(a).flatMap(f) === f(a)
C) m.flatMap(f).flatMap(g) === m.flatMap(x => f(x).flatMap(g))
D) m.map(id) === m
Show Answer
B) Left Identity means wrapping a value in context (of) and flatMapping is the same as directly applying the function to the value. A is Right Identity, C is Associativity, D is the Functor law.
Quiz 4
What is the type and value of result?
import * as O from "fp-ts/Option";
import { pipe } from "fp-ts/function";
const result = pipe(
O.some(5),
O.map(x => x * 2),
O.flatMap(x => x > 5 ? O.some(x) : O.none),
);
A) Option<number> with value some(10)
B) Option<number> with value none
C) number with value 10
D) Compilation error
Show Answer
A) some(5) is mapped to some(10), then in flatMap, 10 is greater than 5 so some(10) is returned. Type is Option<number>, value is some(10).
Quiz 5
Which statement about the Functional Core / Imperative Shell pattern is correct?
A) The Functional Core includes DB access logic B) The Imperative Shell computes business rules C) The Functional Core consists of pure functions, the Shell handles I/O D) Write the Shell first and refactor the Core later
Show Answer
C) The Functional Core processes business logic (calculations, validation, transformations) as pure functions, while the Imperative Shell handles DB, network, file I/O, etc. The Core is easy to test and reusable, while the Shell is kept as thin as possible.
15. References
Books
- Structure and Interpretation of Computer Programs (SICP) - Harold Abelson, Gerald Jay Sussman
- Functional Programming in Scala - Paul Chiusano, Runar Bjarnason
- Category Theory for Programmers - Bartosz Milewski
- Domain Modeling Made Functional - Scott Wlaschin
- Grokking Simplicity - Eric Normand
Online Courses and Documentation
- fp-ts Official Documentation
- Effect Official Website
- Python functools Documentation
- Scala Official Docs - Functional Programming
- Railway Oriented Programming - Scott Wlaschin
Blogs and Articles
- Mostly Adequate Guide to FP (in JS)
- Learn You a Haskell for Great Good
- fp-ts Learning Series
- returns Library Documentation
- Why Functional Programming Matters - John Hughes
Tools and Libraries
- fp-ts - TypeScript FP library
- Effect - Next-gen TypeScript FP framework
- Immer - JavaScript immutability library
- returns - Python FP library
- Ramda - Practical JavaScript FP library