A little rant about functional programming and pure functions. There is a common delusion about pure functions. People often think that:
- Pure functions cannot use variables. Only constants.
- Pure functions cannot run impure functions. Only pure.
These statements make some point. But they are not entirely true. Let me clarify this. The only restrictions that pure functions have are:
- It must be deterministic, meaning it doesn’t have random behavior. No matter how many times you run it with the same set of arguments — you’ll get the same result.
- It shouldn’t have any side effects.
What is a side effect? Basically, it’s any change in the world outside of the function. E.g.:
- Changing the given argument
- Changing a variable defined outside of the function
- Mutating any value reachable from outside the function (including parameters) or any value that escapes the function
- Making a query (database, network, etc.)
- Reading a file, writing to a file, removing a file, etc.
- Reading external mutable data
- etc.
I think you got the concept. Now let’s focus on the word “side” in “side effects”. “Side” means it’s outside of the given function. We don’t have these restrictions for internal state. Yep, a pure function may have mutable internal state.
function pure(a: number, b: number): number { let c = a ** b; if (c > 100) c %= 100; return c - 14; }
^ this one is pure, even though it mutates c. Just because c is internal to the function — it doesn’t affect or depend on anything outside. We don’t mutate it after we return the value.
Ok, what about running impure functions? It’s a dangerous zone. Because it is very dependent on how exactly impure the impure function. If it’s not deterministic, then we have no chance. So the question is about: what side effects an impure function may have while still allowing us to use it inside a pure function? The answer is: the only side effect it may have is mutating the given arguments (only if they are local to the pure function):
function impure(packet: Packet): void { // deterministic and based only on packet packet.hash = computeHash(packet); } function pure(id: number, data: Data): Packet { const packet: Packet = { id, data }; impure(packet); return packet; }
Here we clearly run impure in pure. But since impure changes only packet, which is entirely managed by pure, we’re safe.
That’s it.
To make it clearer, here are a few examples of impure behavior:
function impure(x: { v: number }): number { x.v += 1; // mutates caller-visible object → impure return x.v; }
let mutableValues: Record<string, Data>; function impure(id: string) { return mutableValues[id].size; }
function impure(id: number): Promise<User> { return fetch(`/user/${id}`); }