- Part 1: Introduction
- Part 2: Effects
Let's imagine you want to write a function which writes to a file in Node. Here's a simple example:
import * as fs from "fs";
function writeHelloWorld(path: string) {
fs.writeFileSync(path, "Hello world!");
}
writeHelloWorld("my-file.md");
What's the type of our function writeHelloWorld
? fs.writeFileSync
returns void
, so naturally so does writeHelloWorld
.
But let's think about this for a second. What if we took the same function and made it empty? Like this:
function writeHelloWorld(path: string) {
// Nothing to see here!
}
It's got the exact same type (path: string) => void
. But of course they do very different things. What the first example does - writing a file - is called an effect. It's certainly not a pure function.
If you're familiar with React hooks, this is exactly the same concept as
useEffect
, which you use for things like fetching data (which is very much an effect).
We're going to run on the assumption here that writing pure functions is good, and having side effects is bad.
So how could we make writeHelloWorld
pure? Is this possible?
How about we make it lazy by having writeHelloWorld
return a function?
import * as fs from "fs";
function writeHelloWorld(path: string) {
return () => fs.writeFileSync(path, "Hello world!");
}
const runEffects = writeHelloWorld("my-file.md");
Is this code now pure?
- no side effects ✅
- same input always produces same output ✅
Seems pure to me! But it doesn't do anything. We also need to add:
runEffects();
Only this part of our code is impure, because without that, nothing would happen.
Is this cheating? Sort of, but it works.
In fact we can make our entire program pure, despite it being full of effects like file writes and random numbers all over the place. So long as every function with effects is lazy, we can combine all of our effects together into one lazy function - like a big red button to run the program:
// WARNING: do not call this function unless you're sure what you're doing!
function runMyPureProgram() {
return () => {
writeSomeFiles();
getSomeRandomNumbers();
getTodaysDate();
}
}
Calling runMyPureProgram()
is impure, yet the program itself is pure.
But how do we ensure our functions are pure? If I look at getSomeRandomNumbers
and it returns () => number[]
, how do I know if it has an effect or not? If only there was some way the compiler could tell us this... 🤔
Well it's called Type Script for a reason! Let's imagine a new type:
type Effect<T> = () => T;
What we say is, any impure function with effects must return the type Effect
. T
is the return value - for example, if our effect was to read a file as a string it could be Effect<string>
.
We can write:
function writeHelloWorld(path: string): Effect<void> {
return () => fs.writeFileSync(path, "Hello world!");
}
Now, just by looking at the type signature of writeHelloWorld
, we know it's going to be doing some effectful things. In this case, T
is void
since it doesn't return a value.
So if I start adding new impure things in my code, TypeScript will complain until I actually handle the effects correctly at the end of the program.
Through some lazy functions and a new type called Effect
, we've both kept our program pure and we know where the impure effectful things are hiding. 🤯
If you're like me, at this point you might be thinking: "This is great! Wait - what's the point?". It seems like we still have a bunch of impure stuff littered around our codebase, and we've now made the code more awkward than it was before. All in the name of "purity".
Let's stop for a second for a quiz. Imagine this contrived example:
function funcA(): number {
return funcB();
}
function funcB(): number {
return funcC();
}
function funcC(): number {
return 5;
}
We want to change funcC
to return a random number. I'll give you three ways we could do this, which one is the 'best'?
1. The impure way:
function funcC(): number {
return Math.random();
}
Easy! Nothing else to change, let's go home now.
2. Dependency injection:
function funcA(randomGenerator: () => number): number {
return funcB(randomGenerator);
}
function funcB(randomGenerator: () => number): number {
return funcC(randomGenerator);
}
function funcC(randomGenerator: () => number): number {
return randomGenerator();
}
Our functions are still nice and pure, but we had to add an argument to them all, which is a lot of typing...
3. Effects
function funcA(): Effect<number> {
return funcB();
}
function funcB(): Effect<number> {
return funcC();
}
function funcC(): Effect<number> {
return () => Math.random();
}
Everything's still pure but we had to change the return type of everything to make it lazy.
So I'll ask again, which one's the 'best'?
I would say typing effects are a blessing and a curse. It's exactly because they require you to change your function signatures you're forced to program in a way where effects are kept to a minimum in your code. And you're more likely to put them all in one place.
It's like immutability - it can be more annoying to do, but can provide a cleaner codebase overall. Whether the short-term pain is worth the long-term gain is always difficult to say though.
Even if you're now convinced to use the Effect
type, this still isn't perfect. There's nothing in TypeScript which forces us to add an Effect
type for effects, we just have to be vigilant about it. If you want to be forced to do it, that's when you head to pure a functional language like Haskell.
Introducing: IO
The fp-ts library already has a type just like Effect
, and its name is IO
. In fact, its type is exactly as we defined Effect
.
// taken from source code
export interface IO<A> {
(): A
}
The benefit of using IO
is it comes with a lot of helper functions for handling effects. Plus, it provides functions for effectful things (like generating random numbers) which are already typed as IO
. Here are some simple examples:
Add a number
Let's start here:
import { IO } from "fp-ts/lib/IO";
import { random } from "fp-ts/lib/Random";
function funcA(): IO<number> {
return funcB();
}
function funcB(): IO<number> {
// importing random from fp-ts is the same as
// return () => Math.random();
return random;
}
Imagine we want to add 1 in funcA
:
function funcA(): IO<number> {
return funcB() + 1;
}
Oops! We get the error:
Operator '+' cannot be applied to types 'IO<number>' and 'number'.
We could change funcA
to this:
function funcA(): IO<number> {
return () => funcB()() + 1;
}
But that's annoying and plain ugly. Instead fp-ts gives us io.map
:
import { IO, io } from "fp-ts/lib/IO";
function funcA(): IO<number> {
return io.map(funcB(), (x) => x + 1);
}
Much like you call .map
on an array, you can map
something of type IO
too.
Logging a random number
We can use io.chain
to combine two effects into a single effect:
import { IO, io } from "fp-ts/lib/IO";
import { random } from "fp-ts/lib/Random";
import { log } from "fp-ts/lib/Console";
function logRandomNumber(): IO<void> {
return io.chain(random, log);
}
const program = logRandomNumber();
// this logs a random number when called
program();
In this way we can combine all of our effects into one program
, which we then call right at the very end.
There's much more to learn that we haven't covered (I haven't even mentioned error handling), so do check out these resources if you want to find out more:
Top comments (2)
Surely at times this would be unnecessary closure allocation?
Interesting point! I guess you need to weigh up developer productivity vs performance. In the vast majority of cases I doubt this would add any noticeable difference to performance.