Split View: 함수형 프로그래밍 실전 가이드: 백엔드 개발자를 위한 불변성, 순수함수, 모나드의 모든 것
함수형 프로그래밍 실전 가이드: 백엔드 개발자를 위한 불변성, 순수함수, 모나드의 모든 것
- 도입: 왜 2025년에 함수형 프로그래밍인가
- 1. 불변성 (Immutability)
- 2. 순수 함수 (Pure Functions)
- 3. 고차 함수 (Higher-Order Functions)
- 4. 클로저와 커링 (Closures and Currying)
- 5. 대수적 데이터 타입 (Algebraic Data Types)
- 6. Option/Maybe와 Result/Either
- 7. 모나드 쉽게 이해하기
- 8. Functor, Applicative, Monad 계층구조
- 9. FP in Practice: TypeScript (fp-ts / Effect)
- 10. FP in Practice: Python
- 11. FP vs OOP
- 12. FP 패턴: 백엔드 실전
- 13. 면접 질문 15선
- 14. 퀴즈 5선
- 15. 참고 자료
도입: 왜 2025년에 함수형 프로그래밍인가
2025년, 함수형 프로그래밍(FP)은 더 이상 학술적 호기심의 영역이 아닙니다. React Hooks는 함수형 컴포넌트를 표준으로 만들었고, Rust의 소유권 시스템은 불변성을 언어 수준에서 강제합니다. TypeScript 생태계에서 fp-ts와 Effect가 빠르게 성장하고 있으며, Python도 functools와 패턴 매칭을 지속적으로 강화하고 있습니다.
왜 백엔드 개발자에게 FP가 중요한가:
- 동시성 안전성: 불변 데이터는 race condition을 원천 차단합니다
- 테스트 용이성: 순수 함수는 mock 없이 테스트할 수 있습니다
- 합성 가능성: 작은 함수를 조합하여 복잡한 비즈니스 로직을 구축합니다
- 에러 처리: Option/Result 타입으로 null과 예외를 체계적으로 관리합니다
┌─────────────────────────────────────────────────┐
│ FP가 주류가 된 이유 (2025) │
├─────────────────────────────────────────────────┤
│ React Hooks → 함수형 컴포넌트 표준화 │
│ Rust → 소유권 + 불변성 강제 │
│ TypeScript → fp-ts, Effect 생태계 성장 │
│ Java 21+ → Record, Pattern Matching │
│ Python 3.12+ → match 문, functools 강화 │
│ Kotlin → 코루틴 + 함수형 기본 지원 │
└─────────────────────────────────────────────────┘
이 글에서는 FP의 핵심 개념을 TypeScript, Python, Scala 코드와 함께 설명하고, 백엔드 실전 패턴까지 다룹니다.
1. 불변성 (Immutability)
1.1 불변성이란
불변성은 데이터를 한번 생성한 후 절대 변경하지 않는 원칙입니다. 변경이 필요하면 기존 데이터를 복사하여 새로운 데이터를 만듭니다.
// 가변(Mutable) - 위험
const user = { name: "Alice", age: 30 };
user.age = 31; // 원본 변경 → 다른 참조에서 예기치 않은 동작
// 불변(Immutable) - 안전
const user = { name: "Alice", age: 30 };
const updatedUser = { ...user, age: 31 }; // 새 객체 생성
// user는 여전히 { name: "Alice", age: 30 }
1.2 왜 불변성이 중요한가
┌──────────────────────────────────────┐
│ Mutable State의 문제 │
├──────────────────────────────────────┤
│ Thread A: user.balance = 100 │
│ Thread B: user.balance = 200 │
│ 결과: ??? (Race Condition) │
├──────────────────────────────────────┤
│ Immutable State의 해결 │
├──────────────────────────────────────┤
│ Thread A: newUser = {...user, 100} │
│ Thread B: newUser = {...user, 200} │
│ 결과: 각각 독립적 (안전) │
└──────────────────────────────────────┘
1.3 언어별 불변성 지원
TypeScript:
// readonly 키워드
interface User {
readonly name: string;
readonly age: number;
}
// as const
const CONFIG = {
port: 3000,
host: "localhost",
} as const;
// Readonly 유틸리티 타입
type ImmutableUser = Readonly<User>;
// 깊은 불변성 (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!
# 업데이트: replace 사용
from dataclasses import replace
updated = replace(user, age=31)
# NamedTuple도 불변
class Point(NamedTuple):
x: float
y: float
Scala:
// case class는 기본 불변
case class User(name: String, age: Int)
val user = User("Alice", 30)
val updated = user.copy(age = 31)
// 불변 컬렉션이 기본
val list = List(1, 2, 3)
val newList = 0 :: list // List(0, 1, 2, 3) — 원본 유지
1.4 구조적 공유 (Structural Sharing)
불변 데이터가 매번 전체 복사를 한다면 성능 문제가 됩니다. 구조적 공유는 변경되지 않은 부분을 공유하여 이를 해결합니다.
원본 트리: 수정 후 트리:
A A'
/ \ / \
B C B C'
/ \ \ / \ \
D E F D E F'
→ B, D, E는 공유됨 (복사 불필요)
→ A→A', C→C', F→F'만 새로 생성
JavaScript에서는 Immer, Immutable.js가 이를 지원합니다.
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 (구조적 공유)
2. 순수 함수 (Pure Functions)
2.1 순수 함수의 정의
순수 함수는 두 가지 조건을 만족합니다:
- 결정론적(Deterministic): 같은 입력에 항상 같은 출력
- 부작용 없음(No Side Effects): 외부 상태를 변경하지 않음
// 순수 함수
function add(a: number, b: number): number {
return a + b;
}
// 비순수 함수 - 외부 상태 의존
let counter = 0;
function increment(): number {
return ++counter; // 부작용: 외부 변수 변경
}
// 비순수 함수 - 비결정론적
function now(): number {
return Date.now(); // 매번 다른 결과
}
2.2 참조 투명성 (Referential Transparency)
표현식을 그 결과값으로 대체해도 프로그램 동작이 바뀌지 않는 성질입니다.
// 참조 투명
const x = add(2, 3); // 5
// add(2, 3)을 어디서든 5로 대체 가능
// 참조 불투명
const y = Math.random();
// Math.random()을 y의 값으로 대체하면 동작이 달라짐
2.3 부작용 분리 전략
실전에서는 부작용을 완전히 피할 수 없습니다. 핵심은 순수한 부분과 불순한 부분을 분리하는 것입니다.
// 나쁜 예: 비즈니스 로직에 부작용 혼재
async function processOrder(orderId: string) {
const order = await db.findOrder(orderId); // 부작용
const discount = calculateDiscount(order); // 순수
const total = applyDiscount(order.amount, discount); // 순수
await db.updateOrder(orderId, total); // 부작용
await emailService.send(order.email, total); // 부작용
}
// 좋은 예: 순수 로직 분리
// 순수 함수들
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 };
}
// 불순한 쉘 (얇게 유지)
async function processOrder(orderId: string) {
const order = await db.findOrder(orderId);
const result = computeOrderResult(order); // 순수 로직 호출
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) │ │ │
│ │ │ - 계산 │ │ │
│ │ │ - 변환 │ │ │
│ │ │ - 검증 │ │ │
│ │ └────────────────────────┘ │ │
│ └────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────┘
3. 고차 함수 (Higher-Order Functions)
3.1 고차 함수란
함수를 인자로 받거나 함수를 반환하는 함수입니다.
// 함수를 인자로 받음
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;
}
// 함수를 반환
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" },
];
// 명령형 접근
let total = 0;
for (const order of orders) {
if (order.status === "completed") {
total += order.amount;
}
}
// 함수형 접근
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)
작은 함수들을 조합하여 복잡한 변환을 만듭니다.
// 수동 합성
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));
// 사용 예
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 스타일
함수의 인자를 명시적으로 언급하지 않는 스타일입니다.
// 명시적 스타일
const getNames = (users: User[]) => users.map((u) => u.name);
// Point-Free 스타일
const getName = (u: User) => u.name;
const getNames = (users: User[]) => users.map(getName);
// 더 극단적인 Point-Free (fp-ts 스타일)
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 클로저 (Closure)
클로저는 함수가 자신이 선언된 렉시컬 환경을 기억하는 것입니다.
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
실전 활용: 설정 팩토리
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)
다인자 함수를 단인자 함수의 체인으로 변환하는 기법입니다.
// 일반 함수
function add(a: number, b: number): number {
return a + b;
}
// 커링된 함수
function curriedAdd(a: number): (b: number) => number {
return (b: number) => a + b;
}
const add5 = curriedAdd(5);
add5(3); // 8
add5(10); // 15
// 제네릭 커링 유틸리티
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)
커링과 유사하지만, 여러 인자를 한 번에 고정할 수 있습니다.
// 실전 예: API 클라이언트 팩토리
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",
});
// 이후 간단하게 사용
await api("GET", "/users");
await api("POST", "/orders", { item: "book", qty: 1 });
4.4 미들웨어 패턴
type Middleware<T> = (data: T) => T;
function compose<T>(...middlewares: Middleware<T>[]): Middleware<T> {
return (data: T) => middlewares.reduce((acc, mw) => mw(acc), data);
}
// 요청 처리 미들웨어
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)
5.1 합 타입 (Sum Types / Union Types)
여러 가능한 형태 중 하나를 가지는 타입입니다. "A 이거나 B 이거나 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 이면서 B 이면서 C"입니다.
// Tuple
type Coordinate = [number, number];
// Record / Interface
interface User {
name: string;
email: string;
age: number;
}
// 곱 타입의 가능한 값의 수
// boolean x boolean = 2 x 2 = 4가지
type Pair = [boolean, boolean];
// [true, true], [true, false], [false, true], [false, false]
5.3 ADT를 활용한 상태 모델링
// 나쁜 예: boolean 플래그 남발
interface RequestState {
isLoading: boolean;
isError: boolean;
data: Data | null;
error: Error | null;
}
// isLoading=true, isError=true, data=something → 불가능한 상태 표현 가능
// 좋은 예: ADT로 불가능한 상태 제거
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 "대기 중";
case "loading": return "로딩 중...";
case "success": return `데이터: ${state.data}`;
case "error": return `에러: ${state.error.message}`;
}
}
6. Option/Maybe와 Result/Either
6.1 Null의 문제: 10억 달러의 실수
Tony Hoare는 null 참조를 "10억 달러짜리 실수"라고 불렀습니다. Option 타입은 값이 있을 수도, 없을 수도 있음을 타입으로 표현합니다.
// Option 타입 구현
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 실전: 안전한 데이터 접근
// 위험한 코드
function getUserCity(userId: string): string {
const user = db.findUser(userId); // null 가능
const address = user.address; // null이면 에러!
return address.city; // null이면 에러!
}
// 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>
);
}
// 사용
const city = getOrElse(getUserCity("u1"), "Unknown");
6.3 Result/Either: 예외 없는 에러 처리
// Result 타입 구현
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 실전: 유효성 검증 체인
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: "이름은 필수입니다" });
}
function validateEmail(email: string): Result<string, ValidationError> {
return email.includes("@")
? ok(email)
: err({ code: "INVALID_EMAIL", message: "유효한 이메일이 아닙니다" });
}
function validateAge(age: number): Result<number, ValidationError> {
return age >= 18
? ok(age)
: err({ code: "UNDERAGE", message: "18세 이상이어야 합니다" });
}
// 체이닝
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. 모나드 쉽게 이해하기
7.1 모나드란
모나드는 값을 감싸는 컨텍스트(상자)를 가진 타입으로, 연산을 체이닝할 수 있는 인터페이스를 제공합니다.
세 가지 요소:
- 타입 생성자: 값을 감싸는 컨텍스트 (
Option<T>,Result<T, E>,Promise<T>) - of (return/unit): 값을 컨텍스트에 넣음 (
some(5),ok(5),Promise.resolve(5)) - flatMap (bind/chain): 컨텍스트 안의 값에 함수를 적용하되, 중첩을 풀어줌
일반 map: Option<T> → (T → U) → Option<U>
flatMap: Option<T> → (T → Option<U>) → Option<U>
map은 상자 안의 값을 변환
flatMap은 상자 안의 값으로 새 상자를 만들고, 이중 상자를 풀어줌
7.2 상자 비유
┌─────────────────────────────────────────────────┐
│ map: 상자를 열고, 변환하고, 다시 넣음 │
│ │
│ [5] --map(x => x * 2)--> [10] │
│ │
│ flatMap: 상자를 열고, 새 상자를 만듦 │
│ │
│ [5] --flatMap(x => [x, x+1])--> [5, 6] │
│ (이중 상자 [[5, 6]]이 아님!) │
│ │
│ Option 예시: │
│ Some(5) --flatMap(x => Some(x*2))--> Some(10) │
│ None --flatMap(x => Some(x*2))--> None │
│ (None이면 함수 실행 자체를 건너뜀) │
└─────────────────────────────────────────────────┘
7.3 모나드 법칙
// 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 Promise는 모나드인가
// Promise.resolve = of
const p = Promise.resolve(5);
// .then = flatMap (실제로 map과 flatMap 역할을 모두 수행)
p.then((x) => x * 2); // map처럼
p.then((x) => Promise.resolve(x * 2)); // flatMap처럼 (자동 flatten)
// Promise는 "거의" 모나드
// 단, 자동 flatten 때문에 엄밀한 모나드 법칙에서 약간의 차이가 있음
// Promise<Promise<T>>가 불가능 → 항상 Promise<T>로 평탄화됨
7.5 실전: 비동기 파이프라인
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 계층구조
8.1 계층 다이어그램
┌───────────────────────────────────────┐
│ 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
"매핑할 수 있는 것" - 컨텍스트 안의 값을 변환합니다.
// Functor 인터페이스
interface Functor<F> {
map: <A, B>(fa: F<A>, f: (a: A) => B) => F<B>;
}
// Array는 Functor
[1, 2, 3].map(x => x * 2) // [2, 4, 6]
// Option은 Functor
map(some(5), x => x * 2) // some(10)
map(none, x => x * 2) // none
// Promise는 Functor
Promise.resolve(5).then(x => x * 2) // Promise<10>
8.3 Applicative
여러 컨텍스트의 값에 함수를 적용합니다.
// 두 Option 값을 더하고 싶을 때
const a: Option<number> = some(3);
const b: Option<number> = some(4);
// map만으로는 부족:
// map(a, x => map(b, y => x + y)) → Option<Option<number>> 중첩!
// Applicative: 함수도 컨텍스트에 넣어서 적용
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 실전적 의미
Functor: 하나의 값을 변환 → map
Applicative: 여러 독립 값 결합 → 병렬 유효성 검증
Monad: 연쇄적 의존 연산 → 이전 결과에 따라 다음 결정
실전 예:
- Functor: 사용자 이름을 대문자로 변환
- Applicative: 이름, 이메일, 나이를 동시에 검증하여 결과 결합
- Monad: 사용자 조회 → 조회된 사용자의 주문 조회 → 주문의 결제 처리
9. FP in Practice: TypeScript (fp-ts / Effect)
9.1 fp-ts 기초
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 사용
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 사용
const parseAge = (input: string): E.Either<string, number> => {
const age = parseInt(input, 10);
return isNaN(age)
? E.left("유효하지 않은 나이")
: age < 0
? E.left("나이는 음수일 수 없습니다")
: E.right(age);
};
// pipe로 체이닝
const result = pipe(
"25",
parseAge,
E.map((age) => age + 1),
E.fold(
(error) => `에러: ${error}`,
(age) => `내년 나이: ${age}`
)
);
9.2 TaskEither: 비동기 에러 처리
import * as TE from "fp-ts/TaskEither";
// DB 조회를 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}`)
);
// 파이프라인
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 라이브러리
Effect는 fp-ts의 후속 세대로, 보다 실전적이고 강력한 FP 프레임워크입니다.
import { Effect, pipe } from "effect";
// Effect 정의
const divide = (a: number, b: number): Effect.Effect<number, Error> =>
b === 0
? Effect.fail(new Error("Division by zero"))
: Effect.succeed(a / b);
// 파이프라인
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}`)
)
);
// 실행
Effect.runPromise(program).then(console.log);
9.4 Branded 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;
// 타입 안전: userId와 orderId를 혼동할 수 없음
function findOrder(id: OrderId): Order { /* ... */ }
// findOrder(userId("u1")) // 타입 에러!
findOrder(orderId("o1")); // OK
10. FP in Practice: Python
10.1 functools 활용
from functools import reduce, partial, lru_cache
from typing import Callable, TypeVar
T = TypeVar("T")
# partial: 부분 적용
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: 축적
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: 메모이제이션 (순수 함수에 최적)
@lru_cache(maxsize=128)
def fibonacci(n: int) -> int:
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
10.2 itertools 활용
from itertools import chain, groupby, islice, starmap, accumulate
# chain: 이터러블 연결
combined = list(chain([1, 2], [3, 4], [5, 6])) # [1, 2, 3, 4, 5, 6]
# groupby: 그룹화
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_total = list(accumulate([1, 2, 3, 4, 5])) # [1, 3, 6, 10, 15]
10.3 returns 라이브러리
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 사용
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
# 체이닝
result = find_user("u1").bind(get_email) # Some("alice@example.com")
result = find_user("u999").bind(get_email) # Nothing
# Result 사용
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")
# 파이프라인
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 패턴
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)
# 불변 상태 변환 체인
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 비교 표
┌──────────────────┬──────────────────────┬──────────────────────┐
│ 관점 │ OOP │ FP │
├──────────────────┼──────────────────────┼──────────────────────┤
│ 기본 단위 │ 객체 (상태 + 행동) │ 함수 (변환) │
│ 상태 관리 │ 가변 상태 캡슐화 │ 불변 데이터 변환 │
│ 다형성 │ 상속 / 인터페이스 │ 타입 클래스 / 패턴매칭│
│ 코드 재사용 │ 상속 │ 합성 │
│ 부작용 │ 어디서든 허용 │ 격리 및 관리 │
│ 동시성 │ 락 / 동기화 필요 │ 불변성으로 안전 │
│ 에러 처리 │ 예외 (try-catch) │ Result / Either │
│ 디자인 패턴 │ GoF 패턴 │ 모나드, 펑터 등 │
│ 테스트 │ Mock 의존성 주입 │ 순수 함수 직접 테스트 │
│ 학습 곡선 │ 상대적으로 낮음 │ 상대적으로 높음 │
└──────────────────┴──────────────────────┴──────────────────────┘
11.2 언제 FP를 선택하는가
FP가 유리한 경우:
- 데이터 변환 파이프라인 (ETL, 스트림 처리)
- 동시성이 중요한 시스템
- 비즈니스 규칙이 복잡한 도메인 로직
- 높은 테스트 커버리지가 필요한 경우
- 불변성이 중요한 금융/의료 시스템
OOP가 유리한 경우:
- GUI 프레임워크, 게임 개발 (상태 변경 빈번)
- 레거시 시스템과의 통합
- 팀이 OOP에 익숙한 경우
- 프레임워크가 OOP를 전제로 설계된 경우
11.3 하이브리드 접근법
현대 실전에서는 순수 FP나 순수 OOP가 아닌 하이브리드 접근이 가장 효과적입니다.
// 클래스 + 불변성 + 함수형 메서드
class OrderService {
constructor(
private readonly db: Database,
private readonly logger: Logger
) {}
// 순수 함수: 비즈니스 로직
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);
}
// 불순한 쉘: 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 패턴: 백엔드 실전
12.1 Railway Oriented Programming
성공/실패 경로를 두 개의 레일로 시각화하는 에러 처리 패턴입니다.
성공 레일: ──── validate ──── transform ──── save ──── ✓
│ │ │
실패 레일: ──────┼────────────────┼───────────┼──── ✗
│ │ │
유효성실패 변환에러 저장실패
// Railway 함수: 성공이면 다음으로, 실패면 바로 에러 레일
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);
};
}
// 실전: 사용자 등록 파이프라인
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 파이프라인 패턴
// 데이터 처리 파이프라인
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 파이프라인 예시
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 이벤트 소싱과 FP
이벤트 소싱은 본질적으로 함수형 패턴입니다: fold(events, initialState) = currentState
// 이벤트 정의
type AccountEvent =
| { type: "ACCOUNT_OPENED"; owner: string; initialBalance: number }
| { type: "DEPOSITED"; amount: number }
| { type: "WITHDRAWN"; amount: number }
| { type: "CLOSED" };
// 상태 정의
interface AccountState {
owner: string;
balance: number;
status: "active" | "closed";
}
const initialState: AccountState = {
owner: "",
balance: 0,
status: "active",
};
// 리듀서: (State, Event) => State (순수 함수!)
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" };
}
}
// 이벤트 재생으로 현재 상태 복원
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 불변 상태 관리 (Redux 패턴)
// 액션 타입 (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>;
}
// 리듀서: 순수 함수
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. 면접 질문 15선
기초 (1-5)
Q1. 순수 함수란 무엇이며, 왜 중요한가요?
정답 보기
순수 함수는 (1) 같은 입력에 항상 같은 출력을 반환하고 (2) 부작용이 없는 함수입니다. 중요한 이유는 테스트가 쉽고, 추론이 간편하며, 참조 투명성을 보장하여 함수 합성과 최적화(메모이제이션 등)가 가능하기 때문입니다.
Q2. 불변성이 동시성 프로그래밍에 도움이 되는 이유는?
정답 보기
불변 데이터는 여러 스레드에서 동시에 읽어도 안전합니다. 데이터가 변경되지 않으므로 race condition이 발생하지 않고, 락(lock)이 불필요합니다. 각 스레드는 자신만의 새 복사본을 만들기 때문에 다른 스레드에 영향을 주지 않습니다.
Q3. map과 flatMap의 차이점을 설명하세요.
정답 보기
map은 컨테이너 안의 값에 함수를 적용하여 변환합니다. F(A) -> (A -> B) -> F(B). flatMap은 함수가 컨테이너를 반환할 때 중첩을 풀어줍니다. F(A) -> (A -> F(B)) -> F(B). 예를 들어 Option(Option(5))가 아닌 Option(5)를 반환합니다.
Q4. 고차 함수의 예시 3가지를 들어주세요.
정답 보기
(1) Array.map - 함수를 인자로 받아 각 요소에 적용합니다. (2) Array.filter - 조건 함수를 받아 참인 요소만 반환합니다. (3) Array.sort(compareFn) - 비교 함수를 받아 정렬합니다. 추가로 함수를 반환하는 예로는 커링, 팩토리 패턴이 있습니다.
Q5. 클로저와 순수 함수의 관계는?
정답 보기
클로저는 외부 변수를 캡처하는 함수입니다. 캡처한 변수가 불변이면 클로저도 순수 함수가 될 수 있습니다. 그러나 캡처한 변수가 가변이면 부작용이 생겨 순수 함수가 아닙니다. 커링이나 부분 적용에서 클로저는 설정값을 캡처하여 순수 함수를 생성하는 데 자주 사용됩니다.
중급 (6-10)
Q6. 모나드를 비개발자에게 어떻게 설명하시겠습니까?
정답 보기
모나드는 "값을 담는 특별한 상자"라고 비유할 수 있습니다. 이 상자는 (1) 값을 넣을 수 있고(of), (2) 상자 안의 값을 변환할 수 있으며(map), (3) 변환 결과가 또 다른 상자일 때 이중 상자를 풀어줍니다(flatMap). 예를 들어 Promise는 "미래의 값을 담는 상자"이고, .then()으로 값을 변환하면서 중첩된 Promise를 자동으로 풀어줍니다.
Q7. Option/Maybe 타입이 null보다 나은 이유는?
정답 보기
(1) 컴파일 타임에 null 가능성을 강제로 처리합니다 - 깜빡하면 타입 에러. (2) 체이닝이 가능하여 중첩 null 체크가 사라집니다. (3) 명시적으로 "값이 없을 수 있음"을 타입으로 표현합니다. (4) map/flatMap으로 null 전파가 자동으로 됩니다.
Q8. Railway Oriented Programming을 설명하세요.
정답 보기
에러 처리를 두 개의 레일(성공/실패)로 시각화하는 패턴입니다. 각 함수는 Result/Either를 반환하며, 성공이면 다음 함수로 넘어가고, 실패면 즉시 에러 레일로 전환됩니다. try-catch 블록을 없애고, 에러 전파를 함수 합성으로 처리합니다. Scott Wlaschin이 제안한 패턴입니다.
Q9. 이벤트 소싱이 함수형 프로그래밍과 어떻게 연결되나요?
정답 보기
이벤트 소싱의 핵심은 fold(events, initialState) = currentState로, 순수 함수인 리듀서가 이벤트 스트림을 초기 상태에 순차 적용하여 현재 상태를 도출합니다. 상태는 불변이고, 이벤트를 재생(replay)하면 동일한 결과가 보장됩니다. 이는 FP의 불변성, 순수 함수, reduce 패턴의 직접적인 적용입니다.
Q10. 커링과 부분 적용의 차이점은?
정답 보기
커링은 n인자 함수를 n개의 1인자 함수 체인으로 변환하는 것입니다: f(a, b, c) 가 f(a)(b)(c)가 됩니다. 부분 적용은 일부 인자를 고정하여 나머지 인자를 받는 새 함수를 만드는 것입니다: f(a, b, c)에서 a를 고정하면 g(b, c)가 됩니다. 커링된 함수는 자연스럽게 부분 적용을 지원합니다.
심화 (11-15)
Q11. fp-ts에서 TaskEither의 역할은?
정답 보기
TaskEither는 "실패할 수 있는 비동기 연산"을 타입으로 표현합니다. TaskEither(E, A) 는 () => Promise(Either(E, A))와 동일합니다. try-catch 대신 타입 안전한 에러 처리를 제공하며, chain/map/fold로 비동기 파이프라인을 합성할 수 있습니다. DB 조회, API 호출 등 실패 가능한 I/O 작업에 적합합니다.
Q12. Functor, Applicative, Monad의 차이를 설명하세요.
정답 보기
Functor는 map을 가진 타입(컨텍스트 안의 값 변환). Applicative는 여러 컨텍스트의 값에 함수를 적용(ap: 병렬 독립 연산 결합). Monad는 이전 결과에 따라 다음 연산을 결정(flatMap: 순차 의존 연산). 계층적으로 Monad가 가장 강력하며, 모든 Monad는 Applicative이고 모든 Applicative는 Functor입니다.
Q13. 불변 데이터 구조의 성능 문제는 어떻게 해결하나요?
정답 보기
(1) 구조적 공유(Structural Sharing): 변경된 부분만 새로 만들고 나머지는 참조 공유. (2) Persistent Data Structure: 트라이(Trie) 기반으로 O(log32 N) 업데이트. (3) COW(Copy-on-Write): Immer처럼 실제 변경 시에만 복사. (4) Transient: 빌더 패턴처럼 구축 중에는 가변, 완성 후 불변으로 전환.
Q14. 대수적 데이터 타입(ADT)으로 불가능한 상태를 어떻게 제거하나요?
정답 보기
boolean 플래그 조합 대신 Discriminated Union으로 가능한 상태만 명시합니다. 예를 들어 isLoading, isError, data, error 4개 필드 대신 Idle, Loading, Success(data), Error(error) 4개 상태를 정의하면, "로딩 중이면서 에러"같은 불가능한 상태가 타입 수준에서 차단됩니다. 이것은 "Make illegal states unrepresentable" 원칙입니다.
Q15. FP를 기존 OOP 코드베이스에 점진적으로 도입하는 방법은?
정답 보기
(1) 먼저 순수 함수로 유틸리티/헬퍼를 작성합니다. (2) 불변 데이터 패턴 도입 (readonly, Object.freeze, Immer). (3) 비즈니스 로직을 Functional Core / Imperative Shell 패턴으로 분리합니다. (4) Result/Option 타입으로 에러 처리를 개선합니다. (5) 파이프라인 패턴으로 데이터 변환 흐름을 정리합니다. 전면 전환이 아닌 점진적 채택이 핵심입니다.
14. 퀴즈 5선
퀴즈 1
다음 중 순수 함수가 아닌 것은?
A) const add = (a, b) => a + b
B) const now = () => Date.now()
C) const double = (x) => x * 2
D) const head = (arr) => arr[0]
정답 보기
B) Date.now()는 호출할 때마다 다른 값을 반환하므로 비결정론적이고, 순수 함수가 아닙니다.
퀴즈 2
다음 코드의 출력은?
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
정답 보기
B) 18. filter로 [2, 4], map으로 [6, 12], reduce로 6 + 12 = 18.
퀴즈 3
모나드의 세 가지 법칙 중 "좌항등원(Left Identity)"은?
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
정답 보기
B) 좌항등원은 값을 컨텍스트에 넣고(of) flatMap을 하면 함수에 직접 값을 넣은 것과 같다는 법칙입니다. A는 우항등원, C는 결합법칙, D는 Functor 법칙입니다.
퀴즈 4
다음 TypeScript 코드에서 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> 값 some(10)
B) Option<number> 값 none
C) number 값 10
D) 컴파일 에러
정답 보기
A) some(5)를 map으로 some(10)으로 변환, flatMap에서 10이 5보다 크므로 some(10) 반환. 타입은 Option<number>, 값은 some(10)입니다.
퀴즈 5
Functional Core / Imperative Shell 패턴에서 올바른 설명은?
A) Functional Core에 DB 접근 로직을 포함한다 B) Imperative Shell에서 비즈니스 규칙을 계산한다 C) Functional Core는 순수 함수로 구성하고, Shell은 I/O를 담당한다 D) Shell을 먼저 작성하고 Core는 나중에 리팩토링한다
정답 보기
C) Functional Core는 순수 함수로 비즈니스 로직(계산, 검증, 변환)을 처리하고, Imperative Shell은 DB, 네트워크, 파일 등 I/O를 담당합니다. Core는 테스트가 쉽고 재사용 가능하며, Shell은 최대한 얇게 유지합니다.
15. 참고 자료
서적
- 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
온라인 강좌 및 문서
- fp-ts 공식 문서
- Effect 공식 사이트
- Python functools 문서
- Scala 공식 문서 - Functional Programming
- Railway Oriented Programming - Scott Wlaschin
블로그 및 아티클
- Mostly Adequate Guide to FP (in JS)
- Learn You a Haskell for Great Good
- fp-ts 학습 시리즈
- returns 라이브러리 문서
- Why Functional Programming Matters - John Hughes
도구 및 라이브러리
- fp-ts - TypeScript FP 라이브러리
- Effect - 차세대 TypeScript FP 프레임워크
- Immer - JavaScript 불변성 라이브러리
- returns - Python FP 라이브러리
- Ramda - JavaScript 실용 FP 라이브러리
커뮤니티
Functional Programming Practical Guide: Immutability, Pure Functions, and Monads for Backend Developers
- 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