Pattern

Эти рецепты будут наиболее полезны для тех, кто переходит от функциональных библиотек, таких как ramda, к использованию алгебраических типов данных (ADT). Мы будем использовать замечательную библиотеку crocks для ADT и хелперов, хотя вы сможете применять эти концепции и с другими библиотеками. Я сделал акцент на демонстрации практических примеров и шаблонов, не углубляясь в теорию.

Безопасное выполнение опасных функций

Допустим нам нужно использовать функцию из сторонней библиотеки, назовём её darken. Эта функция принимает множитель и значение, определяющее цвет, а возвращает затемнённый оттенок этого цвета.

// darken :: Number -> String -> String
darken(0.1)("gray")
//=> "#343434"

Полезная штука для использования в CSS. Но оказывается, что функция darken не так безобидна, как может показаться. darken выдаёт ошибки, получая непредусмотренные аргументы.

darken(0.1)(null)
=> // Error: Passed an incorrect argument to a color function, please pass a string representation of a color.

С одной стороны, это полезно для отладки, но мы не хотим, чтобы приложение вылетало, если мы просто не задали цвет. Здесь нам поможет tryCatch.

import { darken } from "polished"
import { tryCatch, compose, either, constant, identity, curry } from "crocks"

// safeDarken :: Number -> String -> String
const safeDarken = curry(n =>
  compose(
    either(constant("inherit"), identity),
    tryCatch(darken(n))
  )
)

tryCatch выполняет нашу функцию в блоке try-catch и возвращает Sum Type с именем Result. По своей сути, Sum Type является типом «or». Это означает, что Result может принять значение либо Ok, если операция выполнена успешно, либо Error в случае ошибки. Другие варианты Sum Types могут быть: Maybe, Either, Async и тд. Например, point-free хелпер either берёт значение из Result; если что-то пошло не так, то возвращается дефолтное CSS inherit, а если все прошло хорошо, то мы получаем нужный нам оттенок.

safeDarken(0.5)(null)
//=> inherit

safeDarken(0.25)('green')
//=> '#004d00'

Используем Maybe хелперы для усиления типов

В JavaScript часто бывает, что в функцию приходит не тот тип данных, который мы ожидали, из-за чего мы получаем непредсказуемый результат. Библиотека crocks предлагает нам функции safe, safeAfter и safeLift, которые позволяют выполнять код более предсказуемо, благодаря типу Maybe. Рассмотрим пример, как конвертировать camelCased строку в Title Case.

import { safeAfter, safeLift, isArray, isString, map, compose, option } from "crocks"

// match :: Regex -> String -> Maybe [String]
const match = regex => safeAfter(isArray, str => str.match(regex))

// join :: String -> [String] -> String
const join = separator => array => array.join(separator)

// upperFirst :: String -> String
const upperFirst = x =>
  x.charAt(0)
    .toUpperCase()
    .concat(x.slice(1).toLowerCase())

// uncamelize :: String -> Maybe String
const uncamelize = safeLift(isString, compose(
  option(""),
  map(compose(join(" "), map(upperFirst))),
  match(/(((^[a-z]|[A-Z])[a-z]*)|[0-9]+)/g),
))

uncamelize("rockTheCamel")
//=> Just "Rock The Camel"

uncamelize({})
//=> Nothing

Мы создали вспомогательную функцию match, которая задействует safeAfter, чтобы «сгладить» поведение String.prototype.match, когда нужно вернуть undefined в случае отсутствия совпадений. Предикат isArray проверяет наличие совпадений, если совпадений не найдено — мы получим Nothing, в обратном случае Just [String]. safeAfter отлично служит для выполнения существующих или сторонних функций безопасным способом.

Совет: safeAfter отлично работает с функцией ramda, которая возвращает a | undefined.

Наша функция uncamelize 🐪 выполняется с safeLift(isString); это означает что она будет выполнена, только когда предикат isString вернёт true.

Кроме того, crocks позволяет использовать хелперы prop и propPath , чтобы брать свойства из объектов и массивов.

import { prop, propPath, map, compose } from "crocks"

const goodObject = {
  name: "Bob",
  bankBalance: 7999,
  address: {
    city: "Auckland",
    country: "New Zealand",
  },
}

prop("name")(goodObject)
//=> Just "Bob"
propPath(["address", "city"])(goodObject)
//=> Just "Auckland"

// getBankBalance :: Object -> Maybe String
const getBankBalance = compose(
  map(balance => balance.toFixed(2)),
  prop("bankBalance")
)

getBankBalance(goodObject)
//=> Just '7999.00'
getBankBalance({})
//=> Nothing

Это особенно полезно, когда мы имеем дело с данными, которые не контролируем, например ответы API. Но что произойдёт если разработчики API вдруг решат взять форматирование под свой контроль?

const badObject = { 
  name: "Rambo",
  bankBalance: "100.00",
  address: {
    city: "Hope",
    country: "USA"
  }
}

getBankBalance(badObject) // TypeError: balance.toFixed is not a function :-(

Ошибки выполнения. Мы пытались вызвать метод toFixed для несуществующей строки. Нам нужно убедится, что bankBalance действительно число, перед тем как вызывать toFixed. Давайте решим это с помощью хелпера safe.

import { prop, propPath, compose, map, chain, safe, isNumber } from "crocks"

// getBankBalance :: Object -> Maybe String
const getBankBalance = compose(
  map(balance => balance.toFixed(2)),
  chain(safe(isNumber)),
  prop("bankBalance")
)

getBankBalance(badObject) //=> Nothing
getBankBalance(goodObject) //=> Just '7999.00'

Мы обеспечили доступность результатов функции prop для функции safe(isNumber), которая, кроме того, возвращает Maybe, если результат prop удовлетворяет условиям предиката. Такой пайплайн гарантирует, что последний map, который содержит toFixed будет вызван только если bankBalance является числом.

Чтобы применять его в похожих задачах, имеет смысл сделать из этого паттерна хелпер:

import { Maybe, ifElse, prop, chain, curry, compose, isNumber } from "crocks"

const { of, zero } = Maybe

// propIf :: (a -> Boolean) -> [String | Number] -> Maybe a
const propIf = curry((fn, path) =>
  compose(
    chain(ifElse(fn, of, zero)),
    prop(path)
  )
)

propIf(isNumber, "age", goodObject) 
//=> Just 7999
propIf(isNumber, "age", badObject) 
//=> Nothing

Чтобы код был чистым — используем аппликативы

Часто бывает так, что нам нужно использовать существующую функцию со значениями, которые упакованы в контейнер. Давайте создадим безопасную функцию add, которая допускает только числа. Используем туже идею, что и в предыдущем примере. Первая попытка:

import { Maybe, safe, isNumber } from "crocks"

// safeNumber :: a -> Maybe a
const safeNumber = safe(isNumber)

// add :: a -> b -> Maybe Number
const add = (a, b) => {
  const maybeA = safeNumber(a)
  const maybeB = safeNumber(b)
  
  return maybeA.chain(
    valA => maybeB.map(valB => valA + valB)
  )
}

add(1, 2)
//=> Just 3

add(1, {})
//=> Nothing

Код делает в точности то, что нам нужно, но функция add теперь сложнее, чем просто a + b. Сначала мы должны поднять значения в Maybe, затем нужно получить к ним доступ и в итоге вернуть результат. Нам нужно найти способ сохранить основную функциональность функции add, при этом обеспечить ей возможность работать со значениями, которые содержатся в ADT. В этом нам помогут аппликативные функторы.

Аппликативные функторы похожи на обычные, но помимо map, они также реализуют два дополнительных метода:

of :: Applicative f => a -> f a

of — простой конструктор, который поднимает любое значение, которое вы ему передадите, к нужному типу данных. В других языках он известен, как pure.

Maybe.of(null)
//=> Just null

Const.of(42)
//=> Const 42

А самый интересный нам метод, это — ap:

ap :: Apply f => f a ~> f (a -> b) -> f b

Очень похоже на map, с одним отличием: функция a -> b тоже обернута в f. Посмотрим код в действии:

import { Maybe, safe, isNumber } from "crocks"

// safeNumber :: a -> Maybe a
const safeNumber = safe(isNumber)

// add :: a -> b -> c
const add = a => b => a + b 

// add :: a -> b -> Maybe Number
const safeAdd = (a, b) => Maybe.of(add)
  .ap(safeNumber(a))
  .ap(safeNumber(b))

safeAdd(1, 2)
//=> Just 3

safeAdd(1, "danger")
//=> Nothing

Сначала мы поднимаем функцию add в Maybe, затем применяем к ней Maybe a и Maybe b. До сих пор мы использовали map для доступа к значению внутри контейнера и ap. Другими словами мы получаем доступ к a через safeNumber(a), и применяем его значение к add. В результате получаем Maybe, в котором содержится частично применённое значение add. Повторяем тот же процесс для safeNumber(b), чтобы выполнить функцию add . Итог: если a и b валидны, то результат будет Just, иначе Nothing.

В библиотека crocks есть и другие хелперы: liftA2 и liftN, они позволяют выразить туже концепцию, но в стиле pointfree. Типичный пример:

liftA2(add)(Maybe(1))(Maybe(2))
//=> Just 3

Мы будем широко использовать этот хелпер в разделе Expressing Parallelism.

Подсказка: вы уже знаете, что ap использует map для доступа к значениям, таким образом мы можем делать классные вещи, например, сгенерировать “Декартово произведение” из двух списков:

import { List, Maybe, Pair, liftA2 } from "crocks"

const names = List(["Henry", "George", "Bono"])
const hobbies = List(["Music", "Football"])

List(name => hobby => Pair(name, hobby))
  .ap(names)
  .ap(hobbies)
// => List [ Pair( "Henry", "Music" ), Pair( "Henry", "Football" ), 
// Pair( "George", "Music" ), Pair( "George", "Football" ), 
// Pair( "Bono", "Music" ), Pair( "Bono", "Football" ) ]

Используем Async для обработки предсказуемых ошибок

В библиотеке crocks есть тип данных Async, который позволяет создавать «ленивые асинхронные вычисления». Подробнее об этом можно узнать в документации. В этом разделе мы рассмотрим примеры использования Async для улучшения качества отчётов об ошибках, чтобы было легче их обрабатывать.

Часто возникает потребность делать вызовы API, которые зависят друг от друга. getUser возвращает user entity из GitHub и множество данных вложенных в URL: звёздочки, подписки и др. Давайте спроектируем такой запрос с помощью Async.

import { Async, prop, compose, chain,  safe, isString, maybeToAsync } from "crocks"

const { fromPromise } = Async

// userPromise :: String -> Promise User Error
const userPromise = user => fetch(`https://api.github.com/users/${user}`)
  .then(res => res.json())

// resourcePromise :: String -> Promise Resource Error
const resourcePromise = url => fetch(url)
  .then(res => res.json())

// getUser :: String -> Async User Error
const getUser = compose(
  chain(fromPromise(userPromise)),
  maybeToAsync('getUser expects a string'),
  safe(isString)
)

// getResource :: String -> Object -> Async Resource Error
const getResource = path => user => {
  if (!isString(path)) {
    return Async.Rejected("getResource expects a string")
  }
  return maybeToAsync("Error: Malformed user response received", prop(path, user))
    .chain(fromPromise(resourcePromise))
}

// logError :: (...a) -> IO()
const logError = (...args) => console.log("Error: ", ...args)

// logResponse :: (...a) -> IO()
const logSuccess = (...args) => console.log("Success: ", ...args)

getUser("octocat")
  .chain(getResource("repos_url"))
  .fork(logError, logSuccess)
//=> Success: { ...response }

getUser(null)
  .chain(getResource("repos_url"))
  .fork(logError, logSuccess)
//=> Error: The user must be as string

getUser("octocat")
  .chain(getResource(null))
  .fork(logError, logSuccess)
//=> Error: getResource expects a string

getUser("octocat")
  .chain(getResource("unknown_path_here"))
  .fork(logError, logSuccess)
//=> Error: Malformed user response received

Используя трансформацию maybeToAsync , мы можем перенести все фичи безопасности из Maybe в Async. Мы можем помечать входящие и другие ошибки, как часть потока Async.

Эффективное использование Моноидов

Мы уже использовали моноиды в JavaScript, когда выполняли операции String/Array конкатенацию и добавление чисел. Это просто тип данных, который предлагает нам использовать следующие методы:

concat :: Monoid m => m a -> m a -> m a

concat позволяет объединить два моноида одного типа вместе, заданным способом.

empty :: Monoid m => () => m a

У метода empty есть элемент identity, который возвращает тот же элемент, после конкатенации с другими моноидами того же типа:

import { Sum } from "crocks"

Sum.empty()
//=> Sum 0

Sum(10)
  .concat(Sum.empty())
//=> Sum 10

Sum(10)
  .concat(Sum(32))
//=> Sum 42

Сам по себе этот код не выглядит особо полезным, но в библиотеке crocks есть и другие моноиды с хелперами: mconcat, mreduce, mconcatMap и mreduceMap.

import { Sum, mconcat, mreduce, mconcatMap, mreduceMap } from "crocks"

const array = [1, 3, 5, 7, 9]

const inc = x => x + 1

mconcat(Sum, array)
//=> Sum 25

mreduce(Sum, array)
//=> 25

mconcatMap(Sum, inc, array)
//=> Sum 30

mreduceMap(Sum, inc, array)
//=> 30

Методы mconcat и mreduce применяют concat ко всем элементам из списка, для этого нужен моноид и сам список элементов. Методы отличаются тем, что mconcat возвращает экземпляр моноида, а mreduce возвращает сырое значение. Хелперы mconcatMap и mreduceMap работают схожим образом, за исключением того, что они принимают дополнительную функцию, которая служит для сопоставления каждого элемента перед вызовом concat.

Давайте разберём моноид First из библиотеки crocks. При конкатенации, First всегда возвращает первое непустое значение.

import { First, Maybe } from "crocks"

First(Maybe.zero())
  .concat(First(Maybe.zero()))
  .concat(First(Maybe.of(5)))
//=> First (Just 5)

First(Maybe.of(5))
  .concat(First(Maybe.zero()))
  .concat(First(Maybe.of(10)))
//=> First (Just 5)

Давайте создадим функцию, которая будет доставать первое доступное свойство объекта, используя для этого возможности First.

import { curry, First, mreduceMap, flip, prop, compose } from "crocks"

/** tryProps -> a -> [String] -> Object -> b */
const tryProps = flip(object => 
  mreduceMap(
    First, 
    flip(prop, object),
  )
)
 
const a = {
  x: 5,
  z: 10,
  m: 15,
  g: 12
}

tryProps(["a", "y", "b", "g"], a)
//=> Just 12

tryProps(["a", "b", "c"], a)
//=> Nothing

tryProps(["a", "z", "c"], a)
//=> Just 10

Весьма изящно! Вот ещё один пример: здесь мы создаём наилучший форматер, для случаев, когда нужно работать с различными типами значений.

import { 
  applyTo, mreduceMap, isString, isEmpty, mreduce, First, not, isNumber, chain
  compose, safe, and, constant, Maybe, map, equals, ifElse, isBoolean, option,
} from "crocks";

// isDate :: a -> Boolean
const isDate = x => x instanceof Date;

// lte :: Number -> Number -> Boolean
const lte = x => y => y <= x;

// formatBoolean :: a -> Maybe String
const formatBoolean = compose(
  map(ifElse(equals(true), constant("Yes"), constant("No"))),
  safe(isBoolean)
);

// formatNumber :: a -> Maybe String
const formatNumber = compose(
  map(n => n.toFixed(2)),
  safe(isNumber)
);

// formatPercentage :: a -> Maybe String
const formatPercentage = compose(
  map(n => n + "%"),
  safe(and(isNumber, lte(100)))
);

// formatDate :: a -> Maybe String
const formatDate = compose(
  map(d => d.toISOString().slice(0, 10)),
  safe(isDate)
);

// formatString :: a -> Maybe String
const formatString = safe(isString)

// autoFormat :: a -> Maybe String
const autoFormat = value =>
  mreduceMap(First, applyTo(value), [
    formatBoolean,
    formatPercentage,
    formatNumber,
    formatDate,
    formatString
  ]);

autoFormat(true)
//=> Just "Yes"

autoFormat(10.02)
//=> Just "10%"

autoFormat(255)
//=> Just "255.00"

autoFormat(new Date())
//=> Just "2019-01-14"

autoFormat("YOLO!")
//=> Just "YOLO!"

autoFormat(null)
//=> Nothing

Параллелизм в Pointfree стиле

Иногда нам нужно совершать множество операций с одним фрагментом данных, после чего объединять результаты этих операций. В библиотеке crocks, для этого есть два метода. Первый шаблон использует Product типы Pair и Tuple. Давайте рассмотрим пример, в котором мы будем работать с таким объектом:

{ ids: [11233, 12351, 16312], rejections: [11233] }

Нам нужно написать функцию, которая принимает этот объект и возвращает массив из ids, за исключением отклонённых элементов. Для начала можно попробовать так (JavaScript):

const getIds = (object) => object.ids.filter(x => object.rejections.includes(x))

Этот код, конечно, работает, но до тех пор, пока свойства корректны и определены. Вместо этого сделаем так, чтобы getIds возвращал Maybe. Мы используем хелпер fanout, который принимает две функции, запускает их на одном входе и возвращает два результата.

import { prop, compose, equals, filter, fanout, merge, liftA2 } from "crocks"

/**
 * object :: Record
 * Record :: {
 *  ids: [Number]
 *  rejection: [Number]
 * }
 **/
const object = { ids: [11233, 12351, 16312], rejections: [11233] }

// excludes :: [a] -> [b] -> Boolean
const excludes = x => y => !x.includes(y)

// difference :: [a] -> [a] -> [a]
const difference = compose(filter, excludes)

// getIds :: Record -> Maybe [Number]
const getIds = compose(
  merge(liftA2(difference)),
  fanout(prop("rejections"), prop("ids"))
)

getIds(object)
//=> Just [ 12351, 16312 ]

getIds({ something: [], else: 5 })
//=> Nothing

Одно из главных преимуществ «pointfree» подхода в том, что он подталкивает нас разбивать логику на маленькие части. Теперь у нас есть «многоразовый» хелпер differenceliftA2, как мы видели ранее), который можно использовать для объединения результата.

Во втором методе будем использовать комбинатор converge, для достижения аналогичных результатов. converge принимает три функции и входящее значение, затем применяет это значение ко второй и третьей функции, а их результаты направляет в первую. Используем это, чтобы создать функцию, которая нормализует массив объектов на основе их id. Мы применим моноид Assign, который позволит объединить объекты вместе.

import {
  mreduceMap, applyTo, option, identity, objOf, map,
  converge, compose, Assign, isString, constant
} from "crocks"
import propIf from "./propIf"

// normalize :: String -> [Object] -> Object
const normalize = mreduceMap(
  Assign,
  converge(
    applyTo,
    identity,
    compose(
      option(constant({})),
      map(objOf),
      propIf(isString, "id")
    )
  )
)

normalize([{ id: "1", name: "Kerninghan" }, { id: "2", name: "Stallman" }])
//=> { 1: { id: '1', name: 'Kerninghan' }, 2: { id: '2', name: 'Stallman' } }

normalize([{ id: null}, { id: "1", name: "Knuth" }, { totally: "unexpected" }])
//=> { 1: { id: '1', name: 'Knuth' } }

Обеспечение сохранности данных с помощью traverse и sequence

Мы видели, как работает Maybe и его «друзья» чтобы предоставить нам те типы, на которые мы рассчитываем. Но что происходит, когда мы работаем с типом, который содержит другие значения, например массив или список? Давайте рассмотрим простую функцию, которая возвращает общую длину всех строк, содержащихся в массиве.

import { compose, safe, isArray, reduce, map } from "crocks"

// sum :: [Number] -> Number
const sum = reduce((a, b) => a + b, 0)

// length :: [a] -> Number
const length = x => x.length;

// totalLength :: [String] -> Maybe Number 
const totalLength = compose(
  map(sum),
  map(map(length)),
  safe(isArray)
)

const goodInput = ["is", "this", "the", "real", "life?"]
totalLength(goodInput)
//=> Just 18

const badInput = { message: "muhuhahhahahaha!"}
totalLength(badInput)
//=> Nothing

Отлично. Мы убедились, что наша функция всегда возвращает Nothing, если она не получает массив. Этого достаточно?

totalLength(["stairway", "to", undefined])
//=> TypeError: x is undefined

Не совсем. Эта функция не гарантирует, что список не содержит неприятных сюрпризов. Один из способов решить эту проблему, это определить функцию safeLength, которая работает только со строками:

// safeLength :: a -> Maybe Number 
const safeLength = safeLift(isString, length)

Если в качестве mapping функции мы используем safeLength, вместо length, то получим [Maybe Number] вместо [Number] и больше не сможем использовать функцию sum. Здесь нам поможет sequence.

import { sequence, Maybe, Identity } from "crocks"

sequence(Maybe, Identity(Maybe.of(1)))
//=> Just Identity 1

sequence(Array, Identity([1,2,3]))
//=> [ Identity 1, Identity 2, Identity 3 ]

sequence(Maybe, [Maybe.of(4), Maybe.of(2)])
//=> Just [ 4, 2 ]

sequence(Maybe, [Maybe.of(4), Maybe.zero()])
//=> Nothing

sequence помогает поменять местами внутренний тип и внешний во время выполнения определенного эффекта, учитывая, что внутренний тип является аппликативным. Использование sequence с Identity устанавливает соответствие внутреннего типа и возвращает содержимое, упакованное в контейнер Identity. Для списка и массива, sequence использует reduce, чтобы объединить содержимое с помощью ap и concat. Давайте посмотрим, как это работает на примере переработанной реализации totalLength.

// totalLength :: [String] -> Maybe Number 
const totalLength = compose(
  map(sum),
  chain(sequence(Maybe)),
  map(map(safeLength)),
  safe(isArray)
)

const goodString = ["is", "this", "the", "real", "life?"]
totalLength(goodString)
//=> Just 18

totalLength(["stairway", "to", undefined])
//=> Nothing

Отлично. Теперь totalLength абсолютно надёжна. Такой шаблон маппинга (от a -> m b затем использование sequence) настолько распространён, что у нас появился ещё один хелпер, под названием traverse, который выполняет обе операции вместе. Давайте посмотрим, как можно применять traverse вместо sequence в нашем примере.

// totalLengthT :: [String] -> Maybe Number 
const totalLengthT = compose(
  map(sum),
  chain(traverse(Maybe, safeLength)),
  safe(isArray)
)

Ну вот, работает точно также. Получается, что оператор sequence с identity в качестве маппинг функции, по сути, делает тоже самое, что и traverse.

Примечание: поскольку мы не можем вывести внутренний тип с помощью JavaScript, мы должны явно предоставить конструктор типа в качестве первого аргумента для traverse и sequence.

Становится очевидно насколько sequence и traverse полезны для валидации данных. Давайте создадим универсальный валидатор, который принимает схему и проверяет входной объект. Для этого используем тип Result, который принимает полугруппу с левой стороны, для сбора ошибок. Полугруппа похожа на моноид, она определяет метод concat, но в отличии от моноида не требует наличия метода empty. В коде присутствует функция maybeToResult, она поможет нам взаимодействовать между Maybe и Result.

import {
  Result, isString, map, merge, constant, bimap, flip, propOr, identity, 
  toPairs, safe, maybeToResult, traverse, and, isNumber, compose
} from "crocks"

// length :: [a] -> Int
const length = x => x.length

// gte :: Number -> a -> Result String a
const gte = x => y => y >= x

// lte :: Number -> a -> Result String a
const lte = x => y => y <= x

// isValidName :: a -> Result String a
const isValidName = compose(
  maybeToResult("expected a string less than 20 characters"),
  safe(and(compose(lte(20), length), isString))
)

// isAdult :: a -> Result String a
const isAdult = compose(
  maybeToResult("expected a value greater than 18"),
  safe(and(isNumber, gte(18)))
)

/**
 *  schema :: Schema
 *  Schema :: {
 *    [string]: a -> Result String a
 *  }
 * */
const schema = {
  name: isValidName,
  age: isAdult,
}

// makeValidator :: Schema -> Object -> Result [String] Object
const makeValidator = flip(object =>
  compose(
    map(constant(object)),
    traverse(Result, merge((key, validator) =>
        compose(
          bimap(error => [`${key}: ${error}`], identity),
          validator,
          propOr(undefined, key)
        )(object)
      )
    ),
    toPairs
  )
)

// validate :: Object -> Result [String] Object
const validate = makeValidator(schema)

validate(({
  name: "Car",
  age: 21,
}))
//=> Ok { name: "Car", age: 21 }

validate(({
  name: 7,
  age: "Old",
}))
//=>  Err [ "name: expected a string less than 20 characters", "age: expected a value greater than 18" ]

Поскольку мы перевернули функцию makeValidator, цепочка compose получает схему, которую нам нужно проверить. Сначала мы разбиваем схему на пары «ключ-значение», затем передаём значение каждого свойства соответствующей функции валидации. На случай если в функции произойдёт ошибка, мы применили bimap для map, добавили дополнительную информацию и возвращаем как синглтон массив. Затем traverse сделает конкатенацию всех ошибок, если они были или вернёт исходный объект, если он прошёл валидацию. Ещё мы можем вернуть строку вместо массива, но с массивом как-то удобней.

Перевод статьи Karthik Iyengar: Functional Programming Patterns: A Cookbook