As the top answer says, reading a mutable value is generally considered impure and you can refactor to include the exchange rate. What is not stated is that sometime later you will need an impure function to do real work in the impure world, something like
async function buyCoins(user: User, package: CoinPackage) {
// Random number generation is impure
const id = uuidv4();
// Fetching from a DB is impure,
const exchageRates = await knex.select('*').from('ExchangeRates');
// usdFromPrice can be a pure function
const usdEstimate = usdFromPrice(package.price, exchangeRates);
// but getting a date is not
const createdDate = Date.now() / 1000;
// Saving to a DB is more obviously impure
const coinTransfer = { id, user, package, state: "PENDING", usdEstimate, createdDate };
await knex('CoinTransfers').insert(coinTransfer);
// ...
}
Reading from mutable values, or reading dates or random numbers, can be seen to be impure by a very easy criterion. Why do we like purity? Because pure functions can be composed, cached, optimized, inlined, and most importantly tested in isolation. This is why mocking is so popular, mocks turn an effectful computation into a pure one, allowing unit tests and so forth.
Testing is a great way to ask if you have a pure function. In your case I might write a test, “confirm that 10€ is 11.93 US$” and this test breaks tomorrow! So I have to mock the side-effect, which proves that there was one. Dates are side-effects, sleep()ing is a side effect, these things have no real expressibility in the abstract world of lambda calculus—and you can see this from the fact that you might wish to mock time, for instance to test things like “you can edit a tweet for 15 minutes after you send it, but after that edits should be frozen.”
What does pure-by-default look like?
In languages like Haskell we strive for purity by default, you can still write your const rate = getExchangeRate()
line up-top but it requires a function called unsafePerformIO
, it has the word “unsafe” right there in the name. So for example, I might be writing a Choose-Your-Own-Adventure style game, I might include a file pages.json
that has my level data, and roughly speaking I can say “I know that pages.json
always exists and does not change meaningfully in the course of my game,” and so here I would permit myself some impurity: I would read in that file with unsafePerformIO
. But most of the things I would write, I would not write with direct side-effects. To encapsulate such side-effects in Haskell we do something that you could call metaprogramming, writing programs with other programs—except that sadly, today metaprogramming usually refers to macros (rewriting source code trees with other source code) which is much more powerful and dangerous than this simpler sort of metaprogramming.
Haskell wants you to write out a pure computation which will compute a value called Main.main
, whose type is IO ()
, or “a program producing no values.” Programs are just one of the data types that I can manipulate in Haskell. Then, it is the Haskell compiler's job to give this to take this source file, perform that pure computation, and put that effectful program as a binary executable somewhere on your hard drive to be run later at your leisure. There is a gap, in other words, between the time when the pure computation runs (while the compiler makes the executable) and the time when the effectful program runs (whenever you run the executable).
For a very lightweight example (i.e. not full-featured, not production-ready) of a TypeScript class which describes immutable programs and some stuff you can do with them, consider
export class Program<x> {
// wrapped function value
constructor(public readonly run: () => Promise<x>) {}
// promotion of any value into a program which makes that value
static of<v>(value: v): Program<v> {
return new Program(() => Promise.resolve(value));
}
// applying any pure function to a program which makes its input
map<y>(fn: (x: x) => y): Program<y> {
return new Program(() => this.run().then(fn));
}
// sequencing two programs together
chain<y>(after: (x: x) => Program<y>): Program<y> {
return new Program(() => this.run().then(x => after(x).run()));
}
// maybe we also play with overloads and some variable binding
bind<key extends string, y>(name: key, after: Program<y>): Program<x & { [k in key]: y }>;
bind<key extends string, y>(name: key, after: (x: x) => y): Program<x & { [k in key]: y }>;
bind<key extends string, y>(name: key, after: (x: x) => Program<y>): Program<x & { [k in key]: y }>;
bind<key extends string, y>(name: key, after: Program<y> | ((x: x) => Program<y> | y)): Program<x & { [k in key]: y }> {
return this.chain((x) => {
if (after instanceof Program) return after.map((y) => ({ [name]: y, ...x })) as any;
const computed = after(x);
return computed instanceof Program? computed.map(y => ({ [name]: y, ...x }))
: Program.of({[ name ]: computed as y, ...x });
});
}
}
The key is that if you have a Program<x>
then no side effects have happened and these are totally functionally-pure entities. Mapping a function over a program does not have any side effects unless the function was not a pure function; sequencing two programs does not have any side effects; etc.
Then our above function might start to be written like
function buyCoins(io: IO, user: User, coinPackage: CoinPackage) {
return Program.of({})
.bind('id', io.random.uuidv4)
.bind('exchangeRates', io.biz.getExchangeRates)
.bind('usdEstimate', ({ exchangeRates }) =>
usdFromPrice(coinPackage.price, exchangeRates)
)
.bind(
'createdDate',
io.time.now.map((date) => date.getTime() / 1000)
)
.chain(({ id, usdEstimate, createdDate }) =>
io.biz.saveCoinTransfer({
id,
user,
coinPackage,
state: 'PENDING',
usdEstimate,
createdDate,
})
);
}
The point is that every single function here is a completely pure function; indeed even a buyCoins(io, user, coinPackage)
is a Program
and nothing has actually happened until I actually .run()
to set it into motion.
On the one hand there is a big cost to pay to start using this level of purity and abstraction. On the other hand you might be able to see that the above allows effortless mocking -- just change the io
parameter to one that runs things differently. For example, instead of the production values which might look like
// module io.time
export now = new Program(async () => new Date());
export sleep = new Program(
(ms: number) => new Promise(accept => setTimeout(accept, ms)));
you can for testing mock in a value that does not actually sleep, and has deterministic dates otherwise:
function mockIO(): IO {
let currentTime = 1624925000000;
return {
// ...
time: {
now: new Program(async () => new Date(currentTime)),
sleep: (ms: number) => new Program(async () => {
currentTime += ms;
return undefined;
})
}
};
}
In other languages/frameworks you might instead do this by a large heaping of reflection and autowiring dependency-injection; those work but they involve quite fancy layers of code to enable the basic functionality; by contrast the indirection created by just defining the 30-line class Program<x>
is already strong enough to allow all of this mocking directly, because we're not trying to inject dependencies but merely provide them, which is a much simpler goal.