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.
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' }
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);
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);
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);
A few things to note here:
Sometimes we have to use
fromIOEither
. This is becauseIOEither
is purely sync butTaskEither
is not.fromIOEither
allows us to convert syncIOEither
to a matchingTaskEither
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 haspath
prepended.I'm using
flow
method here. It works just likepipe
(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));
Getting data is very similar to saving it.
getFileData(FILE_PATH)().then(either => fold(console.log, console.log)(either));
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
There is a great series that covers fp-ts in much greater detail than I ever could. Give it a read!
Kyle Simpson has a great series on FrontendMasters
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.
Top comments (0)