将IO映射到fp-ts中的Either数组

时间:2020-03-11 23:22:22

标签: typescript fp-ts

有人可以帮我在fp-ts中弄清楚该怎么做吗?

const $ = cheerio.load('some text');
const tests = $('table tr').get()
  .map(row => $(row).find('a'))
  .map(link => link.attr('data-test') ? link.attr('data-test') : null)
  .filter(v => v != null);

我可以使用TaskEither来完成所有操作,但是我不知道如何将其与IO混合使用,或者也许我根本不应该使用IO

这是我到目前为止想出的:

const selectr = (a: CheerioStatic): CheerioSelector => (s: any, c?: any, r?: any) => a(s, c, r);

const getElementText = (text: string) => {
  return pipe(
    IO.of(cheerio.load),
    IO.ap(IO.of(text)),
    IO.map(selectr),
    IO.map(x => x('table tr')),
    // ?? don't know what to do here
  );
}

更新:

对于我来说,我必须提及并阐明最具有挑战性的部分是如何将类型从IO更改为Either的数组,然后过滤或忽略left,然后继续{ {1}}或Task

TypeScript错误为TaskEither

Type 'Either<Error, string[]>' is not assignable to type 'IO<unknown>'

1 个答案:

答案 0 :(得分:6)

如果要“正确”执行操作,则需要将所有非确定性(非纯)函数调用包装在IO或IOEither中(取决于它们是否可以失败)。

因此,首先让我们定义哪些函数调用是“纯”的,哪些不是。我发现最容易想到的是-如果函数ALWAYS为相同的输入提供相同的输出,并且不会导致任何可观察的副作用,那是纯粹的。

“相同输出”并不意味着引用相等,而是结构/行为相等。因此,如果您的函数返回了另一个函数,则此返回的函数可能不是同一函数对象,但是必须具有相同的行为(对于原始函数而言,它应该是纯函数)。

因此,按照这些术语,是正确的:

  • cherio.load是纯粹的
  • $是纯粹的
  • .get不是纯粹的
  • .find不是纯粹的
  • .attr不是纯粹的
  • .map是纯粹的
  • .filter是纯粹的

现在让我们为所有非纯函数调用创建包装器:

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))

要注意的一件事是,此处我们在元素数组上应用了非纯函数(在包装版本中为.attrattrIO)。如果我们仅将attrIO映射到数组上,则会返回Array<IO<result>>,但它不是超级有用,我们需要IO<Array<result>>。为此,我们需要traverse而不是map https://gcanti.github.io/fp-ts/modules/Traversable.ts.html

因此,如果您有一个数组rows,并且想在其上应用attrIO,则可以这样做:

import { array } from 'fp-ts/lib/Array';
import { io } from 'fp-ts/lib/IO';

const rows: Array<...> = ...;
// normal map
const mapped: Array<IO<...>> = rows.map(attrIO('data-test'));
// same result as above `mapped`, but in fp-ts way instead of native array map
const mappedFpTs: Array<IO<...>> = array.map(rows, attrIO('data-test')); 

// now applying traverse instead of map to "flip" the `IO` with `Array` in the type signature
const result: IO<Array<...>> = array.traverse(io)(rows, attrIO('data-test'));

然后将所有组件组装在一起:

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))
  );
}

现在getTests会返回与原始代码中tests变量中相同元素的IO。

免责声明:我尚未通过编译器运行代码,它可能会有一些错别字或错误。您可能还需要付出一些努力,以使其全部成为强类型。

编辑

如果您想保留有关错误的信息(在这种情况下,data-test元素之一上缺少a属性),您可以通过多种方法来实现。当前getTests返回一个IO<string[]>。要在此处添加错误信息,您可以执行以下操作:

  • IO<Either<Error, string>[]>-返回一个数组的IO,其中每个元素均为错误或值。要使用它,您仍然需要稍后进行过滤以消除错误。这是最灵活的解决方案,因为您不会丢失任何信息,但也感觉有点没用,因为在这种情况下Either<Error, string>string | null几乎一样。
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[]>-返回错误或值数组的IO。在这里,最常见的操作是在获得第一个缺少的属性时返回Error,并在所有值均非错误时返回值数组。同样,如果有任何错误,此解决方案将丢失有关正确值的信息,并且将丢失除第一个错误以外的所有错误的信息。
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')))
  );
}
  • IOEither<Error[], string[]>-返回一个错误数组或值数组的IO。如果有错误,则此操作将汇总错误,如果没有错误,则将汇总值。如果有任何错误,此解决方案将丢失有关正确值的信息。

与上面的方法相比,这种方法在实践中更为罕见,并且实施起来更棘手。一种常见的用例是验证检查,为此,有一个monad转换器https://gcanti.github.io/fp-ts/modules/ValidationT.ts.html。我没有太多经验,所以不能在这个话题上说更多。

  • IO<{ errors: Error[], values: string[] }>-返回包含错误和值的对象的IO。此解决方案也不会丢失任何信息,但实施起来会有些棘手。

规范的做法是为结果对象{ errors: Error[], values: string[] }定义一个monoid实例,然后使用foldMap聚合结果:

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))
  );
}