DEV Community

loading...

Safe Node.js file operations with fp-ts

wojciechmatuszewski profile image Wojciech Matuszewski ・4 min read

As I'm trying to get myself familiar with functional programming, I thought, I might as well put my current skills to the test and write some code.

After juggling with thoughts, I decided to write functional, safe, file-related (read, write) wrappers for Node.js native fs.readFile and fs.writeFile methods.

This article assumes basic knowledge of TypeScript and core concepts of functional programming like composition and partial application and the notion of Functor and Monad.

First things first

I'm going to, in this article, skip over some of the mathematical stuff that goes along with functional programming. While omitted here, it should NOT be*, in general, ignored.

To get started we have to familiarize ourselves with IO, Task and Either functional structures

Either

Either is a structure that has two subtypes:

  • left
  • right

These two subtypes carry a notion of failure (left) and success (right).

It's mostly used for making computations and transformations safe.
Let's say I want to implement safeParseInt. Either is an ideal candidate to do that.

Check this out:

import { Either, left, map, right } from 'fp-ts/lib/Either';
import { increment } from 'fp-ts/lib/function';
import { compose } from 'ramda';

function safeParse(radix: number) {
  return function(strToParse: string): Either<string, number> {
    const n = parseInt(strToParse, radix);
    return isNaN(n) ? left('Parse failed') : right(n);
  };
}

const safeBaseTenParser = safeParse(10);

// You could also use fp-ts flow method
// flow works just like pipe, so you would have to switch the order of computations.
const res = compose(
  map(increment),
  safeBaseTenParser
)('123');

console.log(res);

// { _tag: 'Right', right: 124 }
// To get the actual value you probably should use the fold method.

Enter fullscreen mode Exit fullscreen mode

Since Either is right-biased, all our transformations (increment in this case) will only be applied on the actual, correct, right value.

As soon as we introduce left value, all transformations, that proceed that value, will be ignored:

// ... previous code ... //

const res = compose(
  map(val => {
    console.log('Im here');
    return val;
  }),
  safeBaseTenParser
)('js-is-awesome');

console.log(res) // { _tag: 'Left', left: 'Parse failed' }

Enter fullscreen mode Exit fullscreen mode

console.log in map never fires. That transformation is ignored since we received left value from safeBaseTenParser. How awesome is that?

To implement the aforementioned file operations we are not going to use Either directly, but the notion of left and right value will be present.

IO_(Either)

IO is a computation builder for synchronous computations. That is computations, that can cause side-effects in our program.

By using IOEither we are communicating that these computations can fail, and so we have to deal with right and left values.

We are going to use IOEither for parsing / stringifying values.

import { toError } from 'fp-ts/lib/Either'
import { IOEither, tryCatch as IOTryCatch } from 'fp-ts/lib/IOEither';

const stringifyData = (data: Todo[]) =>
  IOTryCatch(() => JSON.stringify(data), toError);

const parseStringifiedData = (data: string): IOEither<Error, Todo[]> =>
  IOTryCatch(() => JSON.parse(data), toError);
Enter fullscreen mode Exit fullscreen mode

IOTryCatch works like a try{}catch{} block, but returns IOEither so we can compose those operations.

We are also using toError to forward JSON.parse/stringify error to left value.

Task_(Either)

Task is the async version of IO.
Since we want to reap the benefits of non-blocking async operations we need this structure to wrap the fs.readFile and fs.writeFile methods.

import { promisify } from 'util';
import fs from 'fs';
import { tryCatch as TaskEitherTryCatch } from 'fp-ts/lib/TaskEither';
import { toError } from 'fp-ts/lib/Either';

const readFromFile = promisify(fs.readFile);
const writeToFile = promisify(fs.writeFile);

export const getFileContents = (path: string) =>
  TaskEitherTryCatch(() => readFromFile(path, 'utf-8'), toError);

export const writeContentsToFile = (path: string) => (contents: string) =>
  TaskEitherTryCatch(() => writeToFile(path, contents), toError);

Enter fullscreen mode Exit fullscreen mode

Again, we are using tryCatch variant here, which enables us to not worry about implementing our own try{}catch{} blocks.

I'm also creating writeContentsToFile as higher-order function, to make it more reusable and work nicely with composition.

Implementation

These were the main building blocks. Let's put all the pieces together now:


import { flow } from 'fp-ts/lib/function';
import {
  chain as TaskEitherChain,
  fromIOEither,
  map as TaskEitherMap
} from 'fp-ts/lib/TaskEither';

const FILE_NAME = 'data.json';
const FILE_PATH = path.join(__dirname, `./${FILE_NAME}`);

export const getFileData = flow(
  getFileContents,
  TaskEitherChain((rawString: string) =>
    fromIOEither(parseStringifiedData(rawString))
  )
);

export const saveData = (path: string) => (data: Todo) =>
  flow(
    getFileData,
    TaskEitherMap(append(data)),
    TaskEitherChain(todos => fromIOEither(stringifyData(todos))),
    TaskEitherChain(writeContentsToFile(FILE_PATH))
  )(path);

Enter fullscreen mode Exit fullscreen mode

A few things to note here:

  • Sometimes we have to use fromIOEither. This is because IOEither is purely sync but TaskEither is not. fromIOEither allows us to convert sync IOEither to a matching TaskEither structure.

  • If you are unfamiliar with chain method, it allows us to escape nested structures and still map one, in this case, TaskEither to another one.

  • saveData method has this curry-like signature to allow for the creation of independent save managers that has path prepended.

  • I'm using flow method here. It works just like pipe (left to right).

Usage

Saving data is pretty straight forward. We have to supply path and then a Todo.

saveData(FILE_PATH)({
  id: uuid(),
  isDone: false,
  content: 'content'
// getting the actual results using fold
})().then(either => fold(console.log, console.log)(either));
Enter fullscreen mode Exit fullscreen mode

Getting data is very similar to saving it.

getFileData(FILE_PATH)().then(either => fold(console.log, console.log)(either));
Enter fullscreen mode Exit fullscreen mode

saveData and getFileData represent computations that may be unsafe, because of the side-effects. By invoking them we are pulling the pin of grenade hoping for the best.

If any damage is done though, we are pretty sure to where to look for culprits because we contained impurity within these small, composable functions.

Summary

So there you have it.

The world of functional programming is very vast and while I'm only a beginner in this area, I've already been able to introduce a little bit of functional magic into my codebase.

I hope some of you find this article useful.

You can follow me on twitter: @wm_matuszewski

Thanks 👋

Additional resources

Footnotes

*One might argue that knowing how functional programming relates to math is useless. I had the same view, but after learning just enough theorems and mathematical rules that govern these structures, I found it much easier to learn new concepts, because they all are connected by mathematics.

Discussion (0)

pic
Editor guide