Functional Programming Concepts That Make Your Code Better


Functional programming as a topic has an image problem. Mention it and people think of Haskell, category theory, monads, and seminars. That reputation is undeserved. The core ideas are practical, language-agnostic, and immediately useful in JavaScript, Python, TypeScript, Go, or whatever you’re writing today.

You don’t need to adopt a purely functional style. You don’t need to abandon classes. You need to understand a handful of concepts that make code more predictable, more testable, and easier to reason about.

Pure functions

A function is pure if it always returns the same output for the same input and produces no side effects.

// Pure
function add(a, b) {
  return a + b;
}

// Not pure: depends on external state
let taxRate = 0.2;
function calculateTotal(price) {
  return price * (1 + taxRate);
}

// Not pure: modifies external state
function addItem(cart, item) {
  cart.items.push(item); // mutation
  return cart;
}

Pure functions are trivially testable - no setup, no mocks, no cleanup. Call them with inputs, assert on outputs. They’re also safe to run in parallel, safe to memoize, and safe to reorder. When a bug exists in a pure function, you can reproduce it with a unit test and forget about environment.

The practical approach is not to make every function pure - I/O, database calls, and user interaction are all side effects and you can’t write useful software without them. The goal is to separate pure logic from impure operations. Push side effects to the edges of your system; keep the core logic pure.

// Impure: mixes business logic with I/O
async function applyDiscount(userId, code) {
  const user = await db.getUser(userId);
  const discount = discounts[code];
  user.balance -= discount;
  await db.saveUser(user);
}

// Separated: pure logic in the middle
function applyDiscountToUser(user, discountAmount) {
  return { ...user, balance: user.balance - discountAmount };
}

// I/O at the edges
async function applyDiscount(userId, code) {
  const user = await db.getUser(userId);
  const updated = applyDiscountToUser(user, discounts[code]);
  await db.saveUser(updated);
}

applyDiscountToUser is now testable without a database.

Immutability

Immutability means not modifying data after it’s created. Instead of mutating an object, you create a new one with the changes.

// Mutable (bad for reasoning)
function addItem(cart, item) {
  cart.items.push(item);
  cart.total += item.price;
  return cart; // same object, mutated
}

// Immutable
function addItem(cart, item) {
  return {
    ...cart,
    items: [...cart.items, item],
    total: cart.total + item.price,
  };
}

The mutable version is a trap: the caller’s reference to cart is also modified. Any other code holding that reference sees the change without being told. Debugging why a piece of state changed unexpectedly is painful when mutations are scattered across the codebase.

With immutability, data flows in one direction. If you want to know the state at a point in time, it’s the object you have - it hasn’t been quietly modified. This is the reason React made immutable state updates mandatory: knowing whether to re-render requires detecting what changed, and mutation makes that impossible without deep equality checks.

Immutability has a performance cost - creating new objects instead of modifying existing ones uses more memory. In practice this matters less than you’d expect for most application code. For genuinely hot paths, profiling will tell you whether it’s the bottleneck; usually it isn’t.

Map, filter, reduce

These three operations are the functional way to transform collections without loops and mutation.

map transforms each element, returning a new array of the same length.

const prices = [10, 20, 30];
const withTax = prices.map(p => p * 1.2); // [12, 24, 36]

filter returns a new array containing only elements that pass a predicate.

const orders = [
  { id: 1, status: 'pending' },
  { id: 2, status: 'shipped' },
  { id: 3, status: 'pending' },
];
const pending = orders.filter(o => o.status === 'pending'); // [{id:1,...}, {id:3,...}]

reduce accumulates a collection into a single value. It’s more general than map and filter - both can be implemented with reduce.

const total = [10, 20, 30].reduce((sum, n) => sum + n, 0); // 60

// Grouping with reduce
const byStatus = orders.reduce((acc, order) => {
  const group = acc[order.status] ?? [];
  return { ...acc, [order.status]: [...group, order] };
}, {});

These operations compose cleanly. A sequence of data transformations expressed as a .map().filter().reduce() chain is declarative - it says what you want, not how to do it. Compare:

// Imperative
const result = [];
for (const item of inventory) {
  if (item.inStock) {
    result.push({ name: item.name, price: item.price * 0.9 });
  }
}

// Declarative
const result = inventory
  .filter(item => item.inStock)
  .map(item => ({ name: item.name, price: item.price * 0.9 }));

Both are correct. The second is easier to read, easier to extend, and harder to introduce off-by-one errors in.

Function composition

Composition is combining small functions into larger ones. The output of one becomes the input of the next.

const trim    = s => s.trim();
const toLower = s => s.toLowerCase();
const slugify = s => s.replace(/\s+/g, '-');

// Manual composition
const toSlug = s => slugify(toLower(trim(s)));
toSlug('  Hello World  '); // 'hello-world'

// Compose helper
const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);
const pipe    = (...fns) => x => fns.reduce((v, f) => f(v), x);

const toSlug = pipe(trim, toLower, slugify);

pipe applies functions left-to-right (easier to read). compose applies right-to-left (the mathematical convention). Both produce a new function that is the combination of its parts.

The value of composition is that each piece is independently testable and reusable. You test trim, toLower, and slugify separately. toSlug works because its parts work.

Avoiding side effects (and when to accept them)

Side effects are operations that interact with the outside world: writing to a database, logging, modifying DOM, reading a file. They’re necessary. The question is where they belong and how to manage them.

The functional approach: make side effects explicit and localized. Don’t hide I/O inside functions that look like pure computations. A function called formatUserName should not also log to a monitoring service.

Higher-order functions (functions that take or return functions) are a natural tool for keeping side effects separate:

// Side-effect-free: computes what to save
function buildAuditRecord(user, action) {
  return { userId: user.id, action, timestamp: new Date().toISOString() };
}

// Side effect: does the saving
async function logAudit(user, action) {
  const record = buildAuditRecord(user, action);
  await db.insert('audit_log', record);
}

buildAuditRecord is pure and testable. logAudit is the boundary where the side effect lives.


None of these concepts require a new language or framework. Pure functions, immutable data, and composition work in TypeScript as naturally as in Haskell. The payoff is concrete: code that’s easier to test, easier to follow, and less likely to surprise you with hidden state changes at 2am.

The useful question isn’t “is this functional?” It’s “does this function have one clear input and output, or is it secretly modifying state elsewhere?” Answer that honestly, and the rest follows.