top of page

Functional Programming for the Curious

  • ShiftQuality Contributor
  • May 7
  • 5 min read

Functional programming has a marketing problem. Its advocates describe it using words like "monad," "functor," and "referential transparency." These words mean things, but they sound like a graduate seminar, and most working developers close the tab.

Which is a shame, because the core ideas of functional programming are simple, practical, and applicable in any language — including the one you already use. You don't need to learn Haskell or switch to F#. You need to understand three concepts that make your code more predictable, easier to test, and less prone to bugs.

The Three Ideas

1. Pure Functions: Same Input, Same Output

A pure function takes input and returns output. That's all it does. It doesn't modify global state. It doesn't write to a database. It doesn't change its arguments. Given the same inputs, it always returns the same result.

# Pure — same input, same output, no side effects
def calculate_tax(subtotal, rate):
    return subtotal * rate

# Impure — modifies external state
total = 0
def add_to_total(amount):
    global total
    total += amount      # side effect: modifies global variable
    return total

Why this matters: pure functions are predictable. calculate_tax(100, 0.08) returns 8.0 every time. You can test it trivially — no setup, no mocking, no database state. You can call it from anywhere without worrying about what else it might change.

The impure function depends on and modifies external state. To test it, you need to know (and control) the value of total. Calling it twice with the same argument gives different results. It's harder to reason about, harder to test, and harder to debug.

The practical takeaway: You can't make everything pure. You need to read databases, write files, and send HTTP requests. But you can separate the pure logic from the impure I/O. Do the computation with pure functions. Do the I/O at the edges.

2. Immutability: Don't Change Things

Instead of modifying data in place, create new data with the changes applied.

// Mutable — modifies the original
function addItem(cart, item) {
    cart.items.push(item);          // modifies the original cart
    cart.total += item.price;       // modifies the original cart
    return cart;
}

// Immutable — returns a new cart
function addItem(cart, item) {
    return {
        items: [...cart.items, item],           // new array with item added
        total: cart.total + item.price          // new total
    };
}

Why this matters: when data doesn't change, a whole category of bugs disappears. You never wonder "who changed this?" because nobody changed it. There's no race condition where two threads modify the same object simultaneously. There's no function that silently corrupts your data as a side effect.

The practical concern: "Isn't creating new objects all the time wasteful?" For most applications, the performance difference is negligible. Modern runtimes are optimized for this pattern. For the rare cases where it matters (processing millions of records per second), you can use mutable operations in those specific hot paths while keeping the rest of the code immutable.

The practical takeaway: Default to immutability. Use const instead of let in JavaScript. Use readonly in C#. Use frozen dataclasses in Python. Return new objects instead of modifying existing ones. Make mutable the exception, not the rule.

3. Functions as First-Class Citizens

Functions can be stored in variables, passed as arguments, and returned from other functions. This lets you compose behavior by combining small functions.

def apply_discount(rate):
    def discounter(price):
        return price * (1 - rate)
    return discounter

ten_percent_off = apply_discount(0.10)
twenty_percent_off = apply_discount(0.20)

ten_percent_off(100)     # 90.0
twenty_percent_off(100)  # 80.0

apply_discount doesn't calculate a discount — it creates a function that calculates a discount at a specific rate. This is function composition: building complex behavior from simple, reusable pieces.

You already do this if you've used .map(), .filter(), .reduce(), event handlers, middleware, or callbacks. These are all functional programming patterns dressed in everyday clothes.

FP in Languages You Already Use

JavaScript

JavaScript supports FP natively. Array methods are the gateway:

const prices = [10, 20, 30, 40, 50];

// Imperative
let total = 0;
for (let i = 0; i < prices.length; i++) {
    if (prices[i] > 20) {
        total += prices[i];
    }
}

// Functional
const total = prices
    .filter(price => price > 20)
    .reduce((sum, price) => sum + price, 0);

The functional version is a pipeline: filter out prices under 20, then sum the rest. Each step is a pure transformation. No mutation, no index variables, no off-by-one errors.

C#

LINQ is functional programming for C# developers:

var total = prices
    .Where(p => p > 20)
    .Sum();

C# also supports immutable records (record types), pattern matching, and expressions that enable a functional style within an object-oriented language.

Python

List comprehensions, generators, and functools support a functional style:

total = sum(p for p in prices if p > 20)

Python isn't a functional language, but it supports functional patterns well enough to benefit from them.

F#: The Functional .NET Language

If you work in .NET and want to go deeper into FP, F# is functional-first while running on the same platform as your C# code.

let total =
    prices
    |> List.filter (fun p -> p > 20)
    |> List.sum

The pipe operator (|>) makes the data flow explicit — data flows left to right through transformations. F# enforces immutability by default, has algebraic data types for modeling domains, and interoperates fully with C# libraries.

When FP Helps Most

Data transformation. Taking input data, transforming it through a series of steps, and producing output. ETL pipelines, report generation, API response shaping — these are naturally functional: input → transform → output.

Concurrent code. Immutable data eliminates race conditions. Pure functions don't share state. FP patterns make concurrent code safer by construction rather than by careful locking.

Business logic. Pure functions that encode business rules are trivially testable: given these inputs, expect this output. No database setup, no mocking, no complex test fixtures.

Complex state management. Redux in React, Elm architecture, event sourcing — these are functional patterns for managing state changes as sequences of immutable transformations rather than in-place mutations.

When FP Doesn't Help

I/O-heavy code. Reading files, making HTTP requests, querying databases — these are inherently impure. Functional languages have techniques for managing this (monads in Haskell, computation expressions in F#), but the practical approach in most languages is to keep I/O at the edges and logic in the center.

Performance-critical inner loops. Creating new objects on every iteration can stress the garbage collector. In truly hot paths, mutable operations are appropriate. But measure first — the performance difference is usually smaller than developers assume.

When the team doesn't know it. FP patterns in a team that writes imperative code create confusion, not quality. Introduce concepts gradually — start with pure functions and immutability before introducing higher-order function composition.

Key Takeaway

Functional programming is three practical ideas: pure functions (same input, same output), immutability (create new data instead of modifying existing data), and functions as values (compose behavior from small, reusable pieces). You can use these in any language — JavaScript, C#, Python, F#. They make code more predictable, testable, and safe for concurrency. You don't need to learn Haskell. You need to default to const, return new objects, and write functions that don't surprise you.

Comments


bottom of page