The Honest Definition of Functional Programming: Types as Sets, Functions as Pipes, and Why Purity Pays (Part 1 of the Functional Programming Series)
Part 1 of the Functional Programming in Practice series. All TypeScript, no Haskell required, no burritos.
Hey there, Coding Chefs! 👨💻
Ask a programmer "do you use functions?" and you'll get a funny look. "Obviously. I've got a function to fetch the time, one to save a user to the database, one to read where the mouse is. I use functions all day."
Put a mathematician in the room and she winces. "None of those are functions. A function is a mapping from a set of inputs to a set of outputs. double(3) is 6, today, tomorrow, always. The thing that saves to your database? That's not a mapping, that's an event with a side effect."
They're both using the same word for two different things, and that gap is the entire reason functional programming exists. The mathematician's definition is stricter, and this whole series is about what you get when you hold your code to it.
What Functional Programming Actually Is
Here is the definition I wish someone had given me years ago, stripped of ceremony.
Functional programming is a way to design your solution, where you build the answer by transforming values through pure functions and composing them together, instead of mutating state until you get the result you want.
That's it. Read it again. The word "design" matters. FP is not a language. You do not need Haskell. You do not need to quit your job and learn Lisp. If your language treats functions as values you can pass around (and JavaScript does), you can think functionally today.
Compare it to how most of us learned to code. In imperative or object-oriented style, you keep a bunch of variables and you mutate them step by step until the program arrives at the answer. You hold the current state in your head, you change it, you change it again, and somewhere in those changes a bug sneaks in.
Functional programming makes four concrete commitments instead:
- Values over state. You create new values from old ones. You do not edit things in place.
- Expressions over statements. Everything evaluates to a value you can use, instead of performing an action and returning nothing.
- Functions as first-class values. A function is data. You pass it around, return it, store it.
- Effects pushed to the edges. The messy stuff (network, database, files, the clock) lives in a thin layer at the boundary, and the core of your app stays clean.
By deliberately limiting what you are allowed to do, FP gives you more control, which leads to fewer bugs and code that is genuinely easier to reason about. That trade sounds bad until you have lived it. Remember goto? Early programmers fought hard for the freedom to jump anywhere in the code. Then we collectively agreed that freedom caused more pain than it was worth, and we gave it up for structured loops and never looked back. Giving up mutation feels the same. It feels limiting for about a week, and then you stop missing it.
There is a quote I love from Charles Scalfani's Functional Programming Made Easier:
Bad designs produce unexpected consequences, whereas good designs produce unexpected benefits.
That line is the whole pitch. Let me show you the foundation it rests on.
A Type Is a Set of Values
This is the single most important idea in the entire series, and almost nobody states it plainly. Hold onto it.
A type is just a set of values.
booleanis a set with exactly two values:trueandfalse.numberis a set of all the numbers your runtime can hold.- A custom type like
type Direction = "up" | "down"is a set with two values. neveris the empty set. It contains nothing. No value is ever anever.
When you write let x: boolean, you are saying "x is one of the values in the boolean set." That is all a type annotation means. A type draws a circle around a group of values and gives the circle a name.
The number of values in a type even has a name: its cardinality. boolean has cardinality 2. A type that is boolean plus one extra value would have cardinality 3. This sounds like trivia right now. Later in this series, when we kill null for good, "this type has exactly one more value than that type" becomes the precise tool that ends an entire category of crashes. File it away.
A Function Is a Pipe
If a type is a set of values, then a function is a pipe between two sets.
I picture functions as literal pipes. A value goes in one end, gets transformed, and a value comes out the other end. Take a simple increment pipe: every integer that goes in comes out as the integer one higher.
const increment = (n: number): number => n + 1;
The input set is "all numbers." The output set is "all numbers." increment connects them: 3 goes in, 4 comes out. Every single time.
Here are two more pipes to make it concrete:
const toString = (n: number): string => `${n}`; // numbers in, strings out
const isEven = (n: number): boolean => n % 2 === 0; // numbers in, true/false out
toString takes the set of all numbers and maps each one to a string. Notice something: it does not produce every possible string. The string "book" is never the output of toString. A function always covers its entire input set, but it only needs to land somewhere inside its output set, not fill it completely.
And one hard rule that defines what a function even is: each input maps to exactly one output. You can never have two arrows leaving the same input value. Give increment the number 3 and it gives back 4, today, tomorrow, and at 2am during an incident. One input, one output, no exceptions.
That last property has a name, and it is the first of three things a function needs to be a real functional pipe.
The Three Rules of a Trustworthy Pipe
Functions in functional programming are a little stricter than the JavaScript functions you write every day. They follow three rules. When a function follows all three, we call it a Pure Function, and pure functions are the whole point.
Rule 1: It must be Total
A Total function is defined for every value in its input set. You hand it anything from the input universe and it gives you back a real answer.
The opposite is a Partial function: there is at least one input it cannot handle. The classic example is division.
const divide = (a: number, b: number): number => a / b;
What is divide(10, 0)? In JavaScript you get Infinity, which is a lie dressed up as a number. In many languages you get a thrown exception. Either way, divide is partial: it is not honestly defined over zero. There is a hole in its input set.
Functional programming only deals with total functions. And here is a preview of where the series is going: a huge amount of FP is really just "techniques for honestly turning partial functions into total ones." When we reach Option and Either, you will see that they exist precisely to fill these holes without lying.
Rule 2: It must be Deterministic
A Deterministic function returns the same output every time you give it the same input. increment(3) is always 4. There is no hidden dice roll.
This rules out things like Math.random() and Date.now(), which return something different on every call. That probably raises an alarm in your head: then how do I ever generate a random number, or read the clock? Good. Hold that question. It is the single most common objection to FP, and the IO and Task part answers it completely. For now, just notice that a function with hidden randomness is a function you cannot fully predict.
Rule 3: It must have no Side Effects
A function has a side effect when it reads or writes anything outside its own body. Writing to a file. Calling an API. Reading a global variable. Logging to the console. Touching the DOM. Reading or writing a database.
let total = 0;
const addToTotal = (n: number): number => {
total += n; // reaches OUTSIDE the function. side effect.
console.log(total); // also a side effect.
return total;
};
addToTotal depends on the outside world and changes the outside world. Call it twice with the same argument and you get different answers, because total kept changing. It is impossible to reason about in isolation.
You are probably thinking the obvious thing: every useful program does side effects. How is this supposed to work? The short answer, which we will build out properly later, is that we keep the body of our functions pure and push every side effect into a thin layer at the very edge of the application. Instead of writing to a file in the middle of your logic, you build a small description that says "write this text to this file," pass that description around as a value, and only actually perform the write at the boundary, once, on purpose. Do not worry if that feels hand-wavy now. It will click in the IO part. Just plant the idea: pure core, effects at the edge.
Put the three rules together and you get the definition:
A Pure Function is Total, Deterministic, and free of side effects.
Immutability: The Quiet Partner
There is a fourth idea that travels everywhere with purity, and it is immutability. In functional programming we never mutate or change a value. We only create new values from old ones through transformation.
Most of us reach for mutation by reflex:
// the reflex: mutate in place
const addRole = (user: User, role: string): User => {
user.roles.push(role); // we just changed the caller's object
return user;
};
That push reaches into the object the caller handed us and edits it. Somewhere else in the app, code that held a reference to that same user just had its data change underneath it, with no warning. This is the source of an enormous share of real-world bugs, the kind where "nothing changed but it broke."
The functional version refuses to touch the original:
// derive a new value, leave the original alone
const addRole = (user: User, role: string): User => ({
...user,
roles: [...user.roles, role],
});
We return a brand new user with the new role added. The original is untouched. Anyone holding the old reference is safe. Yes, the gut reaction is "isn't copying expensive?" Usually no, and we will cover the persistent-data-structure tricks that make "copy on update" cheap in a later part. For now, the principle stands: derive, do not mutate.
The Payoff: Referential Transparency
Here is what all this discipline buys you, and it is bigger than it looks.
When a function is pure and your data is immutable, you can always replace a call to that function with its result, and the program behaves exactly the same. increment(3) and 4 are interchangeable. Everywhere increment(3) appears, you could paste 4 instead and nothing changes.
This property is called Referential Transparency, and it is the superpower underneath the whole paradigm.
Think about what it unlocks:
- Testing. A pure function has no setup, no mocks, no teardown. Same input, same output. You assert and move on. (In a later part we will see how this makes property-based testing natural, where you check thousands of generated inputs at once.)
- Caching. If
expensiveThing(x)always returns the same value, you can memoize it freely. No staleness, ever. - Concurrency and parallelism. Pure functions do not share or fight over state, so you can run them in parallel without locks. This is not a small thing. It is the reason the whole industry keeps drifting toward functional ideas.
- Reading the code. This is my favorite. When you read functional code, you read it top to bottom, linearly. You do not have to hold every possible state of the program in your head and trace how a variable might have mutated three functions ago. You look forward, not back.
Here is the framing that made it all snap into place for me. If every function is pure, and you build your whole program by composing these functions, then your entire application is really just one big formula. You could, in principle, expand every function into its definition and collapse the whole app into a single expression with no hidden state anywhere. Imperative programming gives you a solution by repeatedly updating state. Functional programming gives you a solution as a formula you assemble from small, honest pieces. That mental model is the thing I want you to carry into every part that follows.
Where This Is Going
Everything in this series stands on what we just covered. Let me show you the road so the abstractions ahead feel earned instead of arbitrary.
We have pure pipes now. Next we learn to compose them, welding small pipes into bigger ones. Then we model loops without for using recursion and lock in immutable data. Then the data-modeling payoff: Option to kill null, Either to turn errors into values instead of explosions, and ADTs with pattern matching to make illegal states impossible to even write. After that comes the hinge, a 20-minute tour of category theory that makes every scary word afterward obvious. Then the real tools: Monoid, Functor, which turns out to be what .map() was always about, type classes and higher-kinded types, where we meet the honest limits of TypeScript and how fp-ts works around them, and finally IO and Task, which is where "no side effects" stops being a paradox and starts being an architecture.
Every part shows the same problem two ways: the imperative version you write today, then the functional version, then what actually changed (fewer branches, fewer tests, whole classes of bug gone).
The Honest Conclusion
Functional programming is a set of constraints you accept on purpose, because the constraints buy you trust. A pure function is one you can read once, test without ceremony, run in parallel, cache for free, and replace with its own answer without breaking anything. That is a function you will never have to debug at 2am, because there is nothing hidden in it to debug.
The four words to carry forward: types are sets, functions are pipes. The three rules: Total, Deterministic, no side effects. The one payoff: referential transparency, which means your whole app becomes a formula you can actually reason about.
You will feel the limits before you feel the benefits. You will reach for a for loop and a mutation and a console.log out of muscle memory and have to stop yourself. Push through the week of awkwardness. On the other side, you start composing solutions like Lego, and you genuinely wonder how you ever shipped without it.
The pure core is the part of your system you never have to debug at 2am. Everything else in this series is about protecting it.
Next up: We compose our pipes. We will build compose and pipe from scratch in about three lines, learn currying (the trick that makes sum(1) quietly become increment), and turn a pile of tiny functions into a clean data-cleaning pipeline. See you in the next part.