DEV Community

Ed Bentley
Ed Bentley

Posted on

Mind-bending functional programming with TypeScript - part 2

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");
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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!");
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Imagine we want to add 1 in funcA:

function funcA(): IO<number> {
  return funcB() + 1;
}
Enter fullscreen mode Exit fullscreen mode

Oops! We get the error:

Operator '+' cannot be applied to types 'IO<number>' and 'number'.
Enter fullscreen mode Exit fullscreen mode

We could change funcA to this:

function funcA(): IO<number> {
  return () => funcB()() + 1;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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:

Discussion (2)

Collapse
zorbyte profile image
zorbyte

Surely at times this would be unnecessary closure allocation?

Collapse
edbentley profile image
Ed Bentley Author

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.