Skip to content
Published on

Functional Programming Practical Guide: Immutability, Pure Functions, and Monads for Backend Developers

Authors

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 HooksFunctional components standard │
RustOwnership + immutability       │
TypeScript     → fp-ts, Effect ecosystem growth │
Java 21+Records, Pattern MatchingPython 3.12+   → match statement, functools     │
KotlinCoroutines + 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 = 100Thread B: user.balance = 200Result: ??? (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 AA', C→C', FF' 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:

  1. Deterministic: Same input always produces the same output
  2. 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:

  1. Type constructor: The context wrapping a value (Option<T>, Result<T, E>, Promise<T>)
  2. of (return/unit): Puts a value into the context (some(5), ok(5), Promise.resolve(5))
  3. flatMap (bind/chain): Applies a function to the value inside the context, flattening nested contexts
Regular map:  Option<T>   (TU)Option<U>
flatMap:      Option<T>   (TOption<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>  (AF<B>)F<B>│                                       │
│    ┌───────────────────────────────┐  │
│    │        Applicative            │  │
│    │  ap: F<AB>F<A>F<B>    │  │
│    │                               │  │
│    │    ┌───────────────────────┐  │  │
│    │    │      Functor          │  │  │
│    │    │  map: F<A>  (AB)   │  │  │
│    │    │       → 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

┌──────────────────┬──────────────────────┬──────────────────────┐
AspectOOPFP├──────────────────┼──────────────────────┼──────────────────────┤
Basic unit       │ Object (state+behav)Function (transform)State mgmt       │ Mutable encapsulate  │ Immutable transform  │
PolymorphismInheritance/IfaceType class/MatchingCode reuse       │ InheritanceCompositionSide effects     │ Anywhere allowed     │ Isolated/managed     │
ConcurrencyLocks/sync needed    │ Safe via immutability│
Error handling   │ Exceptions try-catchResult / EitherDesign patterns  │ GoF patterns         │ Monads, functors     │
TestingMock/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

Blogs and Articles

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

Communities