Functional Programming
Why Functional Programming Matters
Functional programming (FP) produces predictable, testable, and maintainable code through immutability, pure functions, and composition. Production applications benefit from FP patterns that eliminate side effects, enable confident refactoring, and simplify concurrent code.
Core Benefits:
- Predictability: Same inputs always produce same outputs (no hidden state)
- Testability: Pure functions easy to test (no mocking required)
- Composability: Small functions combine into complex behaviors
- Maintainability: No side effects simplifies reasoning about code
- Concurrency safety: Immutable data eliminates race conditions
Problem: Imperative code with mutable state, side effects, and tight coupling is difficult to test, reason about, and maintain as complexity grows.
Solution: Use functional programming patterns (immutability, pure functions, composition) and libraries like fp-ts for production-grade functional programming in TypeScript.
Standard Library First: Basic FP with JavaScript/TypeScript
JavaScript/TypeScript provide functional programming primitives without external dependencies.
Immutability with const and Spread
Immutability prevents accidental mutation and makes data flow explicit.
Pattern:
// Primitive immutability with const
const name = "Alice";
// => const prevents reassignment
// => name = "Bob" would cause compile error
// Array operations without mutation
const numbers = [1, 2, 3];
// => Original array
const newNumbers = [...numbers, 4];
// => Spread creates new array
// => Original array unchanged
// => newNumbers: [1, 2, 3, 4]
console.log(numbers);
// => [1, 2, 3] (original unchanged)
const doubled = numbers.map((n) => n * 2);
// => map creates new array
// => Original array unchanged
// => doubled: [2, 4, 6]
const evens = numbers.filter((n) => n % 2 === 0);
// => filter creates new array
// => evens: [2]
const sum = numbers.reduce((acc, n) => acc + n, 0);
// => reduce aggregates to single value
// => sum: 6
// Object operations without mutation
interface User {
id: number;
name: string;
email: string;
}
const user: User = {
id: 1,
name: "Alice",
email: "alice@example.com",
};
// => Original user object
const updatedUser = {
...user,
name: "Alice Updated",
};
// => Spread creates new object
// => Original user unchanged
// => updatedUser: { id: 1, name: "Alice Updated", email: "alice@example.com" }
console.log(user.name);
// => "Alice" (original unchanged)
// Nested object updates
interface Address {
street: string;
city: string;
}
interface UserWithAddress {
id: number;
name: string;
address: Address;
}
const userWithAddress: UserWithAddress = {
id: 1,
name: "Alice",
address: {
street: "123 Main St",
city: "Boston",
},
};
const updatedAddress = {
...userWithAddress,
address: {
...userWithAddress.address,
city: "New York",
},
};
// => Spread both outer and inner objects
// => Creates new objects at each level
// => Original userWithAddress unchanged
Density: 33 code lines, 39 annotation lines = 1.18 density (within 1.0-2.25 target)
Pure Functions
Pure functions have no side effects and always return the same output for the same input.
Pattern:
// ❌ IMPURE: Depends on external state
let total = 0;
// => Mutable state
function addToTotal(value: number): void {
// => Side effect: modifies external state
total += value;
// => Changes global variable
// => Same input produces different results
}
addToTotal(5);
// => total is now 5
addToTotal(5);
// => total is now 10 (different result for same input!)
// ✅ PURE: No side effects, deterministic
function add(a: number, b: number): number {
// => Takes all inputs as parameters
// => Returns result without side effects
return a + b;
// => Always returns same result for same inputs
}
console.log(add(2, 3));
// => 5 (every time)
console.log(add(2, 3));
// => 5 (same result, predictable)
// ❌ IMPURE: Side effect (logging)
function calculateTotalImpure(prices: number[]): number {
// => Has side effect (console.log)
const total = prices.reduce((acc, price) => acc + price, 0);
console.log(`Total: ${total}`);
// => Side effect: writes to console
// => Cannot test without capturing console output
return total;
}
// ✅ PURE: No side effects
function calculateTotal(prices: number[]): number {
// => Pure calculation, no side effects
// => Returns result only
return prices.reduce((acc, price) => acc + price, 0);
// => Deterministic, testable
}
// Logging moved to caller (separation of concerns)
const total = calculateTotal([10, 20, 30]);
// => Pure calculation
console.log(`Total: ${total}`);
// => Side effect at boundary (acceptable)
// ❌ IMPURE: Mutates input
function sortNumbersImpure(numbers: number[]): number[] {
// => Sorts array in-place (mutation)
return numbers.sort((a, b) => a - b);
// => Mutates original array
// => Unexpected side effect for caller
}
const nums = [3, 1, 2];
const sorted = sortNumbersImpure(nums);
console.log(nums);
// => [1, 2, 3] (original array modified!)
// ✅ PURE: Creates new array
function sortNumbers(numbers: number[]): number[] {
// => Creates copy, no mutation
return [...numbers].sort((a, b) => a - b);
// => Spread creates new array
// => Original array unchanged
}
const nums2 = [3, 1, 2];
const sorted2 = sortNumbers(nums2);
console.log(nums2);
// => [3, 1, 2] (original unchanged)
console.log(sorted2);
// => [1, 2, 3] (sorted copy)
Density: 35 code lines, 44 annotation lines = 1.26 density (within 1.0-2.25 target)
Function Composition
Compose small functions into larger operations.
Pattern:
// Simple functions
function double(n: number): number {
// => Multiply number by 2
return n * 2;
}
function increment(n: number): number {
// => Add 1 to number
return n + 1;
}
function square(n: number): number {
// => Square number
return n * n;
}
// Manual composition
function doubleIncrementSquare(n: number): number {
// => Compose three operations
// => Read right-to-left: square(increment(double(n)))
return square(increment(double(n)));
// => double(5) = 10
// => increment(10) = 11
// => square(11) = 121
}
console.log(doubleIncrementSquare(5));
// => 121
// Generic compose function
function compose<A, B, C>(f: (b: B) => C, g: (a: A) => B): (a: A) => C {
// => Compose two functions right-to-left
// => g executes first, f executes second
// => Returns new function
return (a: A) => f(g(a));
// => Apply g, then apply f to result
}
const incrementThenDouble = compose(double, increment);
// => Compose increment (first) and double (second)
// => Returns function: number => number
console.log(incrementThenDouble(5));
// => increment(5) = 6
// => double(6) = 12
// => Result: 12
// Compose multiple functions
function composeAll<T>(...fns: Array<(x: T) => T>): (x: T) => T {
// => Compose array of functions
// => All functions must have same type signature
return (x: T) => fns.reduceRight((acc, fn) => fn(acc), x);
// => reduceRight: Apply from right to left
// => Accumulator starts with input value
}
const pipeline = composeAll(square, increment, double);
// => Compose: double (first), increment, square (last)
console.log(pipeline(5));
// => double(5) = 10
// => increment(10) = 11
// => square(11) = 121
// Pipe (left-to-right composition)
function pipe<T>(...fns: Array<(x: T) => T>): (x: T) => T {
// => Pipe functions left-to-right (more intuitive)
return (x: T) => fns.reduce((acc, fn) => fn(acc), x);
// => reduce: Apply from left to right
}
const pipeline2 = pipe(double, increment, square);
// => Read left-to-right: double, then increment, then square
console.log(pipeline2(5));
// => Same result: 121
Density: 34 code lines, 38 annotation lines = 1.12 density (within 1.0-2.25 target)
Higher-Order Functions
Functions that take or return other functions enable powerful abstractions.
Pattern:
// Function that returns function (currying)
function multiply(factor: number): (n: number) => number {
// => Returns function that multiplies by factor
// => Partial application of multiplication
return (n: number) => n * factor;
// => Returned function captures factor (closure)
}
const double = multiply(2);
// => Returns function that doubles
const triple = multiply(3);
// => Returns function that triples
console.log(double(5));
// => 10
console.log(triple(5));
// => 15
// Function that takes function (abstraction)
function applyTwice<T>(fn: (x: T) => T, value: T): T {
// => Apply function twice to value
// => Generic: works with any type
return fn(fn(value));
// => fn(value) first, then fn(result)
}
function increment(n: number): number {
return n + 1;
}
console.log(applyTwice(increment, 5));
// => increment(5) = 6
// => increment(6) = 7
// => Result: 7
// Array operations are higher-order functions
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map((n) => n * 2);
// => map takes function, applies to each element
// => [2, 4, 6, 8, 10]
const evens = numbers.filter((n) => n % 2 === 0);
// => filter takes predicate function
// => [2, 4]
// Custom higher-order function
function retry<T>(fn: () => Promise<T>, times: number): Promise<T> {
// => Retry async function specified times
// => Returns promise of result
return fn().catch((error) => {
// => Try function, catch errors
if (times <= 1) {
// => No retries left
throw error;
// => Rethrow error
}
return retry(fn, times - 1);
// => Retry recursively with fewer attempts
});
}
// Usage
await retry(() => fetch("https://api.example.com/data"), 3);
// => Try fetch up to 3 times
// => Returns result or throws after exhausting retries
Density: 32 code lines, 41 annotation lines = 1.28 density (within 1.0-2.25 target)
Limitations of standard library for production:
- Partial type safety: Generic types lose precision in complex compositions
- No Option/Either types: Must manually handle null/undefined everywhere
- No railway-oriented programming: Error handling requires try/catch everywhere
- Limited composability: Difficult to compose async operations with error handling
- No algebraic data types: Cannot express complex domain types elegantly
- Deep nesting: Spread operator becomes unwieldy for nested immutability
- Performance: Spreading large objects/arrays has runtime cost
When standard library suffices:
- Simple transformations (map/filter/reduce)
- Learning FP fundamentals
- Small projects (≤500 lines)
- No complex error handling required
Production Framework: fp-ts
fp-ts provides production-grade functional programming with Option, Either, Task, and more.
Installation and Setup
npm install fp-ts
# => Install fp-ts library
# => Provides functional programming primitives
# => Zero dependenciesOption Type for Nullable Values
Option type explicitly models presence/absence of values.
Pattern:
import { Option, some, none, map, getOrElse } from "fp-ts/Option";
import { pipe } from "fp-ts/function";
// => Import Option type and utilities
// => some: Creates Option with value
// => none: Creates empty Option
// => pipe: Function composition utility
// Traditional null handling
function findUserById(id: number): User | null {
// => Returns User or null
// => Caller must check for null
if (id === 1) {
return { id: 1, name: "Alice", email: "alice@example.com" };
}
return null;
// => Null represents absence
}
// fp-ts Option handling
function findUserByIdOption(id: number): Option<User> {
// => Returns Option<User>
// => Option explicitly models maybe value
if (id === 1) {
return some({ id: 1, name: "Alice", email: "alice@example.com" });
// => some wraps value in Option
// => Indicates value present
}
return none;
// => none represents absence
// => Type-safe empty Option
}
// Traditional null checking
const user = findUserById(1);
if (user !== null) {
// => Manual null check required
console.log(user.name.toUpperCase());
// => Safe after null check
}
// fp-ts Option usage
const userOption = findUserByIdOption(1);
// => Returns Option<User>
const userName = pipe(
userOption,
// => Start with Option<User>
map((user) => user.name),
// => map transforms value inside Option
// => If none, stays none
// => If some, applies function
// => Returns Option<string>
map((name) => name.toUpperCase()),
// => Chain another transformation
// => Returns Option<string>
getOrElse(() => "Unknown"),
// => Extract value or use default
// => No null checks needed
);
// => userName: string (always defined)
console.log(userName);
// => "ALICE" if user found
// => "Unknown" if user not found
// => No null pointer exceptions possible
Density: 33 code lines, 45 annotation lines = 1.36 density (within 1.0-2.25 target)
Either Type for Error Handling
Either type represents success (Right) or failure (Left) without exceptions.
Pattern:
import { Either, left, right, map as mapEither, mapLeft, getOrElse as getOrElseEither } from "fp-ts/Either";
import { pipe } from "fp-ts/function";
// => Import Either type and utilities
// => left: Creates Left (error)
// => right: Creates Right (success)
// Traditional try/catch error handling
function parseIntTraditional(input: string): number {
// => May throw exception
const num = parseInt(input, 10);
if (isNaN(num)) {
throw new Error("Invalid number");
// => Exception disrupts control flow
}
return num;
}
try {
const result = parseIntTraditional("abc");
// => May throw
console.log(result);
} catch (error) {
// => Catch block required
console.error("Error:", error);
}
// fp-ts Either error handling
function parseIntEither(input: string): Either<string, number> {
// => Returns Either<Error, Success>
// => Left<string>: Error case with message
// => Right<number>: Success case with value
const num = parseInt(input, 10);
if (isNaN(num)) {
return left("Invalid number");
// => left represents error
// => No exception thrown
}
return right(num);
// => right represents success
// => Error handling explicit in type
}
const result = pipe(
parseIntEither("123"),
// => Either<string, number>
mapEither((n) => n * 2),
// => Transform right value (if success)
// => If left (error), stays left
// => Returns Either<string, number>
mapLeft((error) => `Parse error: ${error}`),
// => Transform left value (if error)
// => Returns Either<string, number>
getOrElseEither(() => 0),
// => Extract value or use default
// => No try/catch needed
);
// => result: number (always defined)
console.log(result);
// => 246 if parsing succeeded
// => 0 if parsing failed
// Chaining Either operations
interface User {
id: number;
email: string;
}
function validateEmail(email: string): Either<string, string> {
// => Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return left("Invalid email format");
// => Validation error
}
return right(email);
// => Valid email
}
function createUser(email: string): Either<string, User> {
// => Create user from email
// => Returns Either<Error, User>
return pipe(
validateEmail(email),
// => Either<string, string>
mapEither((validEmail) => ({
// => Transform to User (if validation succeeded)
id: Date.now(),
email: validEmail,
})),
// => Returns Either<string, User>
);
// => Error propagates automatically (no nested try/catch)
}
const userResult = createUser("alice@example.com");
// => Either<string, User>
// => Explicit error handling in type
Density: 38 code lines, 54 annotation lines = 1.42 density (within 1.0-2.25 target)
Task for Async Operations
Task type represents async computations that never fail (use TaskEither for failures).
Pattern:
import { Task } from "fp-ts/Task";
import { TaskEither, tryCatch, map as mapTE, chain as chainTE } from "fp-ts/TaskEither";
import { pipe } from "fp-ts/function";
// => Import async types and utilities
// => Task: Async operation that never fails
// => TaskEither: Async operation that may fail
// Traditional async/await
async function fetchUserTraditional(id: number): Promise<User> {
// => May throw exception
const response = await fetch(`https://api.example.com/users/${id}`);
// => Network request may fail
if (!response.ok) {
throw new Error("Fetch failed");
// => Exception for error case
}
return await response.json();
// => Parsing may fail
}
// fp-ts TaskEither (async with error handling)
function fetchUser(id: number): TaskEither<Error, User> {
// => Returns TaskEither<Error, User>
// => Explicit error type in return type
// => Never throws exceptions
return tryCatch(
// => Wrap async operation
() =>
fetch(`https://api.example.com/users/${id}`).then((response) => {
// => Async fetch
if (!response.ok) {
throw new Error("Fetch failed");
// => Error caught by tryCatch
}
return response.json();
// => Parse JSON
}),
(error) => error as Error,
// => Error handler: Convert caught error to Error type
// => tryCatch catches exceptions and wraps in Left
);
// => Returns TaskEither: Left<Error> or Right<User>
}
// Sequential async operations with error propagation
function getUserEmail(userId: number): TaskEither<Error, string> {
// => Fetch user and extract email
// => Error handling automatic
return pipe(
fetchUser(userId),
// => TaskEither<Error, User>
mapTE((user) => user.email),
// => Transform User to email string
// => Returns TaskEither<Error, string>
// => If fetch failed, error propagates (no manual checking)
);
}
// Composing async operations
function processUserData(userId: number): TaskEither<Error, string> {
// => Complex async workflow
return pipe(
fetchUser(userId),
// => Fetch user
chainTE((user) =>
// => chain for dependent operations
// => If fetchUser fails, rest doesn't execute
tryCatch(
// => Second async operation
() => fetch(`https://api.example.com/orders/${user.id}`).then((r) => r.json()),
(error) => error as Error,
),
),
// => Returns TaskEither<Error, Order[]>
mapTE((orders) => `User has ${orders.length} orders`),
// => Transform to summary string
);
// => All errors handled consistently (no nested try/catch)
}
// Execute TaskEither
const emailTask = getUserEmail(1);
// => TaskEither<Error, string> (lazy, not executed yet)
emailTask().then((result) => {
// => Execute task (returns Promise<Either<Error, string>>)
if (result._tag === "Right") {
// => Check if success (right)
console.log("Email:", result.right);
// => Access value from Right
} else {
// => Error case (left)
console.error("Error:", result.left);
// => Access error from Left
}
});Density: 38 code lines, 54 annotation lines = 1.42 density (within 1.0-2.25 target)
Immutable Updates with Lenses
fp-ts provides optics (lenses) for immutable nested updates.
Pattern:
import { Lens } from "monocle-ts";
// => monocle-ts: Optics library for fp-ts
// => Provides lenses for nested updates
interface Address {
street: string;
city: string;
zipCode: string;
}
interface User {
id: number;
name: string;
address: Address;
}
// Traditional nested immutable update
const user: User = {
id: 1,
name: "Alice",
address: {
street: "123 Main St",
city: "Boston",
zipCode: "02101",
},
};
const updatedUser = {
...user,
address: {
...user.address,
city: "New York",
},
};
// => Manual spreading at each level
// => Verbose for deep nesting
// Lens-based update
const addressLens = Lens.fromProp<User>()("address");
// => Lens focusing on address field
// => Type-safe lens creation
const cityLens = Lens.fromProp<Address>()("city");
// => Lens focusing on city field
const userCityLens = addressLens.compose(cityLens);
// => Compose lenses to focus on nested field
// => Type-safe composition
const updated = userCityLens.set("New York")(user);
// => Immutable update via lens
// => Creates new objects at each level
// => Original user unchanged
console.log(user.address.city);
// => "Boston" (original unchanged)
console.log(updated.address.city);
// => "New York" (new object)
// Modify nested value with function
const uppercased = userCityLens.modify((city) => city.toUpperCase())(user);
// => Apply function to nested value
// => Returns new User with modified city
Density: 31 code lines, 35 annotation lines = 1.13 density (within 1.0-2.25 target)
Production benefits:
- Type-safe error handling: Errors explicit in types (Either<Error, Value>)
- Railway-oriented programming: Errors propagate automatically (no manual checking)
- Composable async: TaskEither composes async operations with error handling
- No exceptions: All errors handled via types (no try/catch)
- Null safety: Option type eliminates null pointer exceptions
- Immutable updates: Lenses simplify deep nested updates
Trade-offs:
- External dependency: fp-ts library (500KB)
- Learning curve: Functional programming concepts (Option, Either, Task)
- More verbose: Type signatures longer than traditional code
- Performance: Functional abstractions have runtime cost
When to use fp-ts:
- Complex error handling (multiple failure modes)
- Async workflows (composing async operations)
- Domain-driven design (encoding business rules in types)
- Team familiar with FP (or willing to learn)
Functional Programming Progression Diagram
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC
%% All colors are color-blind friendly and meet WCAG AA contrast standards
graph TB
A[Basic FP: map/filter/reduce] -->|Error handling needs| B[fp-ts Option/Either]
A -->|Async composition needs| C[fp-ts Task/TaskEither]
A -->|Deep nesting| D[monocle-ts Lenses]
A:::standard
B:::framework
C:::framework
D:::framework
classDef standard fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef framework fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
subgraph Standard[" Standard Library "]
A
end
subgraph Production[" fp-ts Ecosystem "]
B
C
D
end
style Standard fill:#F0F0F0,stroke:#CC78BC,stroke-width:3px
style Production fill:#F0F0F0,stroke:#029E73,stroke-width:3px
Production Best Practices
Separate Pure and Impure Code
Keep side effects at boundaries (functional core, imperative shell).
Pattern:
// ❌ BAD: Mixed pure and impure
function processOrder(orderId: string): void {
// => Side effects mixed with logic
const order = fetchOrderFromDB(orderId);
// => Side effect: Database access
const total = order.items.reduce((sum, item) => sum + item.price, 0);
// => Pure calculation
console.log(`Total: ${total}`);
// => Side effect: Logging
sendEmail(order.customerEmail, `Total: ${total}`);
// => Side effect: Email
}
// ✅ GOOD: Separate pure and impure
// Pure calculation (core)
function calculateOrderTotal(items: OrderItem[]): number {
// => Pure function: no side effects
return items.reduce((sum, item) => sum + item.price, 0);
// => Always returns same result for same input
}
// Impure orchestration (shell)
async function processOrderSeparated(orderId: string): Promise<void> {
// => Side effects at boundaries
const order = await fetchOrderFromDB(orderId);
// => Side effect: Database (boundary)
const total = calculateOrderTotal(order.items);
// => Pure calculation (core logic)
console.log(`Total: ${total}`);
// => Side effect: Logging (boundary)
await sendEmail(order.customerEmail, `Total: ${total}`);
// => Side effect: Email (boundary)
}
// => Benefits: calculateOrderTotal is testable without mocking
Use Immutable Data Structures
Prefer immutability for predictable behavior.
Pattern:
// ❌ BAD: Mutation
function addItemBad(cart: CartItem[], item: CartItem): void {
// => Mutates cart array
cart.push(item);
// => Caller's array modified (unexpected)
}
const cart = [{ id: 1, name: "Book" }];
addItemBad(cart, { id: 2, name: "Pen" });
// => cart is now modified (side effect)
// ✅ GOOD: Immutable
function addItem(cart: CartItem[], item: CartItem): CartItem[] {
// => Returns new array
return [...cart, item];
// => Original cart unchanged
// => Predictable behavior
}
const cart2 = [{ id: 1, name: "Book" }];
const newCart = addItem(cart2, { id: 2, name: "Pen" });
// => cart2 unchanged, newCart has new item
Avoid Premature Abstraction
Start concrete, refactor to abstraction when patterns emerge.
Pattern:
// ❌ BAD: Over-abstraction (premature)
function genericProcessor<T, R>(
data: T[],
validator: (item: T) => boolean,
transformer: (item: T) => R,
aggregator: (results: R[]) => R,
): R {
// => Too generic, hard to understand
return aggregator(data.filter(validator).map(transformer));
}
// ✅ GOOD: Concrete first, abstract later
function calculateTotalPrice(items: OrderItem[]): number {
// => Specific, clear purpose
return items
.filter((item) => item.price > 0)
.map((item) => item.price)
.reduce((sum, price) => sum + price, 0);
// => Easy to understand and test
}
// => Refactor to abstraction when pattern repeats 3+ times
Trade-offs and When to Use Each
Standard Library FP (map/filter/reduce)
Use when:
- Simple transformations (array/object operations)
- Learning FP fundamentals
- Small projects (≤500 lines)
- No complex error handling
Avoid when:
- Complex async workflows (use fp-ts TaskEither)
- Multiple failure modes (use fp-ts Either)
- Deep nested updates (use monocle-ts lenses)
fp-ts Library
Use when:
- Complex error handling (multiple failure modes)
- Async workflows (composing async operations)
- Domain-driven design (encoding rules in types)
- Team familiar with FP
Avoid when:
- Simple applications (overkill)
- Team unfamiliar with FP (learning curve)
- Performance-critical code (abstraction overhead)
Common Pitfalls
Pitfall 1: Mutating Objects/Arrays
Problem: Mutation causes unexpected behavior.
Solution: Use spread operator or Array methods that return new arrays.
// ❌ BAD
const numbers = [1, 2, 3];
numbers.push(4);
// => Mutates original array
// ✅ GOOD
const numbers2 = [1, 2, 3];
const updated = [...numbers2, 4];
// => Creates new array
Pitfall 2: Side Effects in Pure Functions
Problem: Hidden side effects make functions unpredictable.
Solution: Keep side effects at boundaries.
// ❌ BAD
function calculateTotal(items: Item[]): number {
console.log("Calculating...");
// => Side effect in pure function
return items.reduce((sum, item) => sum + item.price, 0);
}
// ✅ GOOD
function calculateTotal(items: Item[]): number {
return items.reduce((sum, item) => sum + item.price, 0);
// => Pure calculation only
}
console.log("Calculating...");
// => Side effect at boundary
const total = calculateTotal(items);Pitfall 3: Nested Ternaries
Problem: Nested ternaries are hard to read.
Solution: Use if/else or switch statements.
// ❌ BAD
const status = user ? (user.isActive ? (user.isPremium ? "premium" : "active") : "inactive") : "guest";
// => Unreadable nested ternary
// ✅ GOOD
function getUserStatus(user: User | null): string {
if (!user) return "guest";
if (!user.isActive) return "inactive";
if (user.isPremium) return "premium";
return "active";
}Pitfall 4: Overusing fp-ts
Problem: fp-ts overkill for simple operations.
Solution: Use standard library for simple cases.
// ❌ BAD: Overkill
import { pipe } from "fp-ts/function";
import { map } from "fp-ts/Array";
const doubled = pipe(
[1, 2, 3],
map((n) => n * 2),
);
// ✅ GOOD: Standard library sufficient
const doubled2 = [1, 2, 3].map((n) => n * 2);Summary
Functional programming produces predictable, testable code through immutability, pure functions, and composition. Standard library provides map/filter/reduce for basic FP, while fp-ts adds Option, Either, and Task types for production-grade error handling and async composition.
Progression path:
- Learn with standard library: Master map/filter/reduce and immutability
- Add fp-ts for complexity: Use Option/Either for error handling
- Use TaskEither for async: Compose async operations cleanly
- Apply lenses for nesting: Simplify deep immutable updates
Production checklist:
- ✅ Immutability enforced (const, spread, Array methods)
- ✅ Pure functions preferred (no side effects in core logic)
- ✅ Side effects at boundaries (functional core, imperative shell)
- ✅ Option type for null handling (no null pointer exceptions)
- ✅ Either type for error handling (no exceptions in business logic)
- ✅ TaskEither for async workflows (composable error handling)
- ✅ Function composition (small functions, composed into complex behaviors)
- ✅ Type-safe transformations (generics preserve types)
Choose FP patterns based on complexity: standard library for simple transformations, fp-ts for complex error handling and async workflows.