Not a Harry Potter cloak or spell: wtf is Referential Transparency?

Article #5 · 2026-07-29 · by Luiz Barbosa
#basic#purity#laws

Previously, on Purity

Four episodes ago, we made a promise: same input, same answer, every time. Since then, that promise has been broken in every way this industry knows how. Tod gave away a small fortune in free sushi by writing to a singleton nobody mentioned. Young Stackwalker shipped every possible wrong delivery fee by reading a variable that wasn't ready yet, the worst thing he ever shipped until the Alderaan incident. Katja bought €1.299,00 of catering for €1,30 because a formatter asked her phone for advice. Then we inspected the hotel itself and found the pests pre-installed in the furniture. This is the season finale: the one test that catches every villain we've met.

The substitution game

Referential transparency is the least intimidating idea to ever wear an intimidating name. An expression is referentially transparent when you can replace it with the value it produces, everywhere it appears, forever, and nobody can tell the difference. In practice, it's the find-and-replace test:

func square(_ x: Int) -> Int { x * x }

square(7)   // 49. Always 49. On every phone, in every timezone, in any decade.

Select every square(7) in the codebase, replace with 49, ship it on a Friday, before or after your morning porridge. Nothing changes: not one test, not one customer, not one euro. The call and its value are interchangeable. And the trick doesn't stop at one layer. Put square(7) inside a bigger formula and the substitution game keeps going:

func fallDistance(after seconds: Int) -> Int {
    5 * square(seconds)   // d = ½·g·t², with g rounded to 10 like a proper napkin physicist
}

fallDistance(after: 7)   // 245
5 * square(7)   // 245, inline the body
5 * 49   // 245, replace square(7) with its value
245   // still 245, the expression was its value all along

Every step is the same substitution, applied one layer further out: inline the function, replace the call with its value, fold the arithmetic. Now fallDistance(after: 7) is 245 everywhere and forever, inside any bigger expression that contains it, which is in turn substitutable inside whatever contains that. Referential transparency, like purity itself, is a chain: pure parts make pure wholes, and the property climbs the expression tree all the way to the top of your program. It also breaks like a chain, at the weakest link. Slip a single Date() into that formula and nothing above it survives: the subexpression can't be substituted, so the formula can't, so the function can't, so every caller of every caller can't. Purity composes; impurity is contagious. Speaking of which, play the same game with another innocent-looking expression:

Date().timeIntervalSince1970   // 1784823644.559175
Date().timeIntervalSince1970   // 1784823644.559176, same code, one line later

Two identical expressions, two different values, one line apart. There is no number you can write down that stands for Date(): by the time you've finished typing it, it's wrong. Date() loses the find-and-replace game before you reach the replace button, faster than a JavaScript framework falls out of fashion, faster than Xcode forgets your breakpoints, faster than an incognito tab closes when mommy walks into the room.

One test, every villain

Every disaster in this series was a substitution failure, and the test convicts each villain in its own way. Exhibit A, the Gold Plan preview:

previewGoldPlanTotal(subtotal: 30)   // 28.5

Replace that call with 28.5 and the app gets better: the discount stops accumulating, the singleton stays untouched, nobody eats for free. When substituting a call for its value fixes a bug, you've proven the call was doing something besides producing the value. The mutation was the cargo; the number was the disguise. That's the side effect: the program noticed the swap.

Exhibit B, the delivery fee: plan?.deliveryFee ?? 0. Which value would you substitute, the 0 from the first hundred milliseconds or the 5 after the fetch lands? There is no answer, because the expression doesn't stand for a value; it stands for a question whose answer depends on a race. Katja's 1299.formatted() is the same conviction with a different ambient witness: no string can replace that call, because which string it means is decided in her Settings app. Hidden outputs make the swap change the program; hidden inputs make the swap impossible to even write down. Either way, the expression stops being a reliable stand-in for its value.

And this is why you should care, beyond the pleasure of convicting fictional developers: everything you like about good code is the substitution property in disguise. Testing without mocking half the universe, refactoring without fear, understanding a call without reading its implementation, caching, inlining, reordering, parallelising. All of it is only safe when a call can be thought of as its value, because there is nothing else it was secretly doing or secretly consulting.

The trick we still owe you

Last episode ended with a confession: threading now and newID through every call is a small ceremony, and we hinted the elegant version has a name. The itch is real, and it shows up everywhere purity is taken seriously. Meet Germany's VAT rate:

func addVat(rate: Decimal, to price: Decimal) -> Decimal {
    price * (1 + rate)
}

addVat(rate: 0.19, to: 100)   // 119, Katja's invoices, always 19%

Pure and substitutable: addVat(rate: 0.19, to: 100) is 119 the way square(7) is 49. But every call site in the German storefront repeats rate: 0.19, and sooner or later somebody who's tired of typing it will reach for a constant, then a config, then (if this series has taught us anything) a singleton. Every one of those shortcuts is a crime scene from an earlier episode.

There is a way out, a very Swift-y pattern that pre-applies the decided arguments once and, for every observer on the outside, stays as substitutable as the pure function it wraps. It's an honest compromise, and it deserves its own episode rather than a footnote: structs as frozen arguments, with its stricter cousins Currying: One Argument at a Time and partial application right behind. Until then, the rule of this series stands unamended: every input in the parameter list, every time, even when your fingers protest. Purity is worth the keystrokes.

Roll credits

That's the series. Purity made the promise. Side effects and co-effects were the two ways to break it: hidden outputs and hidden inputs, the ninja and the bedbug. Apple's lovely old hotel showed the pests come pre-installed. And referential transparency is the test that measures all of it: can the call be its value? When the answer is yes, by honest signatures, by inits that only construct, by every input passed in daylight, you get code you can test without ceremony and reason about one value at a time.

The company, incredibly, is still in business. Tod got promoted. Young Stackwalker got a conference talk out of it, and even met the senator. Katja kept her €1.297,70, and the company, still not entirely sure what happened, sent her a coupon. And every function in that checkout now tells the truth, the whole truth, and nothing but the return value.