If you aim to follow best practices, it is essential to encapsulate all non-deterministic (non-pure) function calls within IO or IOEither (depending on their potential failure).
To distinguish between "pure" and non-pure functions, consider this simple rule - if a function consistently produces the same output for the same input without causing any observable side effects, it is deemed pure.
The definition of "same output" does not imply referential equality but rather structural/behavioral equivalence. Therefore, even if a function returns another function that may not be the exact object, it should exhibit identical behavior for the original function to maintain purity.
Based on these criteria, the following classification holds true:
cherio.load
is a pure function
$
is a pure function
.get
is not pure
.find
is not pure
.attr
is not pure
.map
is pure
.filter
is pure
To address non-pure function calls, let's create wrappers as follows:
const getIO = selection => IO.of(selection.get())
const findIO = (...args) => selection => IO.of(selection.find(...args))
const attrIO = (...args) => element => IO.of(element.attr(...args))
It's important to note that when applying a non-pure function like .attr
or attrIO
to an array of elements, mapping attrIO
directly would yield Array<IO<result>>
, which can be enhanced by using traverse
instead of map
.
In the case where you want to apply attrIO
to an array named
rows</code, here's how you can do it:</p>
<pre><code>import { array } from 'fp-ts/lib/Array';
import { io } from 'fp-ts/lib/IO';
const rows: Array<...> = ...;
// regular map
const mapped: Array<IO<...>> = rows.map(attrIO('data-test'));
// equivalent to above with fp-ts functionality
const mappedFpTs: Array<IO<...>> = array.map(rows, attrIO('data-test'));
// leveraging traverse instead of map to align `IO` with `Array` in the type signature
const result: IO<Array<...>> = array.traverse(io)(rows, attrIO('data-test'));
Finally, let's put everything together:
import { array } from 'fp-ts/lib/Array';
import { io } from 'fp-ts/lib/IO';
import { flow } from 'fp-ts/lib/function';
const getIO = selection => IO.of(selection.get())
const findIO = (...args) => selection => IO.of(selection.find(...args))
const attrIO = (...args) => element => IO.of(element.attr(...args))
const getTests = (text: string) => {
const $ = cheerio.load(text);
return pipe(
$('table tr'),
getIO,
IO.chain(rows => array.traverse(io)(rows, flow($, findIO('a')))),
IO.chain(links => array.traverse(io)(links, flow(
attrIO('data-test'),
IO.map(a => a ? a : null)
))),
IO.map(links => links.filter(v => v != null))
);
}
With the updated getTests
, you receive back an IO containing the same elements from your initial tests
variable.
EDIT:
If you wish to retain error information, such as missing data-test
attributes on certain a
elements, there are several approaches available. Currently, getTests
yields an IO<string[]>
. To accommodate error details, consider the following options:
import * as Either from 'fp-ts/lib/Either';
const attrIO = (...args) => element: IO<Either<Error, string>> => IO.of(Either.fromNullable(new Error("not found"))(element.attr(...args) ? element.attr(...args): null));
const getTests = (text: string): IO<Either<Error, string>[]> => {
const $ = cheerio.load(text);
return pipe(
$('table tr'),
getIO,
IO.chain(rows => array.traverse(io)(rows, flow($, findIO('a')))),
IO.chain(links => array.traverse(io)(links, attrIO('data-test')))
);
}
IOEither<Error, string[]>
- an IO delivering either an error or an array of values. Typically, errors are returned upon encountering the first missing attribute, failing to capture other errors or valid values beyond that point.
import * as Either from 'fp-ts/lib/Either';
import * as IOEither from 'fp-ts/lib/IOEither';
const { ioEither } = IOEither;
const attrIOEither = (...args) => element: IOEither<Error, string> => IOEither.fromEither(Either.fromNullable(new Error("not found"))(element.attr(...args) ? element.attr(...args): null));
const getTests = (text: string): IOEither<Error, string[]> => {
const $ = cheerio.load(text);
return pipe(
$('table tr'),
getIO,
IO.chain(rows => array.traverse(io)(rows, flow($, findIO('a')))),
IOEither.rightIO, // "lift" IO to IOEither context
IOEither.chain(links => array.traverse(ioEither)(links, attrIOEither('data-test')))
);
}
This technique, although less common, proves intricate to implement and primarily suitable for validation checks. A possible solution involves employing a monad transformer like . More insights on its practical implications could shed light on better use cases.
A recommended practice involves defining a monoid instance for the resultant object
{ errors: Error[], values: string[] }
and subsequent utilization of
foldMap
to combine outcomes:
import { Monoid } from 'fp-ts/lib/Monoid';
type Result = { errors: Error[], values: string[] };
const resultMonoid: Monoid<Result> = {
empty: {
errors: [],
values: []
},
concat(a, b) {
return {
errors: [].concat(a.errors, b.errors),
values: [].concat(a.values, b.values)
};
}
};
const attrIO = (...args) => element: IO<Result> => {
const value = element.attr(...args);
if (value) {
return {
errors: [],
values: [value]
};
} else {
return {
errors: [new Error('not found')],
values: []
};
};
const getTests = (text: string): IO<Result> => {
const $ = cheerio.load(text);
return pipe(
$('table tr'),
getIO,
IO.chain(rows => array.traverse(io)(rows, flow($, findIO('a')))),
IO.chain(links => array.traverse(io)(links, attrIO('data-test'))),
IO.map(results => array.foldMap(resultMonoid)(results, x => x))
);
}