habrahabr

Элегантная обработка ошибок в JavaScript с помощью монады Either

  • суббота, 22 июня 2019 г. в 00:17:37
https://habr.com/ru/post/457098/
  • JavaScript
  • Программирование


Давайте немного поговорим о том, как мы обрабатываем ошибки. В JavaScript у нас есть встроенная функция языка для работы с исключениями. Проблемный код мы заключаем в конструкцию try...catch. Это позволяет прописать нормальный путь выполнения в разделе try, а затем разобраться со всеми исключениями в разделе catch. Неплохой вариант. Это позволяет сосредоточиться на текущей задаче, не думая о каждой возможной ошибке. Определённо лучше, чем засорять код бесконечными if.

Без try...catch трудно проверять результаты каждого вызова функции для неожиданных значений. Это полезная конструкция. Но у неё есть определённые проблемы. И это не единственный способ обрабатывать ошибки. В статье мы рассмотрим использование монады Either в качестве альтернативы try...catch.

Прежде чем продолжить, отмечу пару моментов. Статья предполагает, что вы уже знаете о композиции функций и каррировании. И предупреждение. Если вы раньше не сталкивались с монадами, они могут показаться действительно… странными. Работа с такими инструментами требует изменить мышление. Поначалу это бывает тяжело.

Не волнуйтесь, если сразу запутались. У всех так. В конце статьи я перечислил несколько ссылок, которые могут помочь. Не сдавайтесь. Эти штуки опьяняют, как только проникают в мозг.

Пример проблемы


Прежде чем обсуждать проблемы исключений, давайте поговорим о том, почему они вообще существуют и почему появились блоки try...catch. Для этого посмотрим на проблему, которую я пытался сделать хотя бы отчасти реалистичной. Представьте, что мы пишем функцию для отображения списка уведомлений. Нам уже удалось (каким-то образом) вернуть данные с сервера. Но по какой-то причине инженеры бэкенда решили отправить его в формате CSV, а не JSON. Необработанные данные могут выглядеть примерно так:

timestamp,content,viewed,href
2018-10-27T05:33:34+00:00,@madhatter invited you to tea,unread,https://example.com/invite/tea/3801
2018-10-26T13:47:12+00:00,@queenofhearts mentioned you in 'Croquet Tournament' discussion,viewed,https://example.com/discussions/croquet/1168
2018-10-25T03:50:08+00:00,@cheshirecat sent you a grin,unread,https://example.com/interactions/grin/88

Мы хотим отобразить его в HTML. Это может выглядеть примерно так:

<ul class="MessageList">
    <li class="Message Message--viewed">
    <a href="https://example.com/invite/tea/3801" class="Message-link">@madhatter invited you to tea</a>
    <time datetime="2018-10-27T05:33:34+00:00">27 October 2018</time>
    <li>
    <li class="Message Message--viewed">
    <a href="https://example.com/discussions/croquet/1168" class="Message-link">@queenofhearts mentioned you in 'Croquet Tournament' discussion</a>
    <time datetime="2018-10-26T13:47:12+00:00">26 October 2018</time>
    </li>
    <li class="Message Message--viewed">
    <a href="https://example.com/interactions/grin/88" class="Message-link">@cheshirecat sent you a grin</a>
    <time datetime="2018-10-25T03:50:08+00:00">25 October 2018</time>
    </li>
</ul>

Чтобы упростить задачу, пока просто сосредоточимся на обработке каждой строки данных CSV. Начнём с нескольких простых функций для обработки строк. Первая делит текстовую строку на поля:

function splitFields(row) {
    return row.split('","');
}

Здесь функция упрощена, потому что это учебный материал. Мы занимаемся обработкой ошибок, а не анализом CSV. Если в одном из сообщений попадётся запятая, всё это будет ужасно неправильно. Пожалуйста, никогда не используйте такой код для анализа реальных данных CSV. Если вам когда-либо приходилось анализировать данные CSV, используйте хорошо протестированную библиотеку парсинга CSV.

После разделения данных мы хотим создать объект. И чтобы каждое имя свойства соответствовало заголовкам CSV. Предположим, мы уже каким-то образом проанализировали строку заголовка (об этом позже). Мы подошли к точке, где что-то может пойти не так. У нас появилась ошибка для обработки. Мы выдаём ошибку, если длина строки не соответствует строке заголовка. (_.zipObject — это lodash-функция).

function zipRow(headerFields, fieldData) {
    if (headerFields.length !== fieldData.length) {
        throw new Error("Row has an unexpected number of fields");
    }
    return _.zipObject(headerFields, fieldData);
}

После этого добавим к объекту человекочитаемую дату, чтобы выдать её в нашем шаблоне. Получилось немного многословно, так как в JavaScript нет идеальной встроенной поддержки форматирования дат. И снова мы сталкиваемся с потенциальными проблемами. Если встретится недопустимая дата, наша функция выдаёт ошибку.

function addDateStr(messageObj) {
    const errMsg = 'Unable to parse date stamp in message object';
    const months = [
        'January', 'February', 'March', 'April', 'May', 'June', 'July',
        'August', 'September', 'October', 'November', 'December'
    ];
    const d = new Date(messageObj.datestamp);
    if (isNaN(d)) {
        throw new Error(errMsg);
    }

    const datestr = `${d.getDate()} ${months[d.getMonth()]} ${d.getFullYear()}`;
    return {datestr, ...messageObj};
}

Наконец, берём объект и передаём его через функцию template, чтобы получить строку HTML.

const rowToMessage = _.template(`<li class="Message Message--<%= viewed %>">
  <a href="<%= href %>" class="Message-link"><%= content %></a>
  <time datetime="<%= datestamp %>"><%= datestr %></time>
<li>`);

Было бы также неплохо напечатать ошибку, если она встретилась:

const showError = _.template(`<li class="Error"><%= message %></li>`);

Когда всё на месте, можно собрать функцию для обработки каждой строки.

function processRow(headerFieldNames, row) {
    try {
        fields = splitFields(row);
        rowObj = zipRow(headerFieldNames, fields);
        rowObjWithDate = addDateStr(rowObj);
        return rowToMessage(rowObj);
    } catch(e) {
        return showError(e);
    }
}

Итак, функция готова. Давайте подробнее рассмотрим, как она управляет исключениями.

Исключения: хорошая часть


Итак, что хорошего в try...catch? Следует отметить, что в приведённом выше примере любой из шагов в блоке try может вызвать ошибку. В zipRow() и addDateStr() мы намеренно выбрасываем ошибки. И если возникает проблема, просто ловим ошибку и показываем любое сообщение на странице. Без этого механизма код становится действительно уродливым. Вот как это может выглядеть. Предположим, что функции не выбрасывают ошибки, а возвращают null.

function processRowWithoutExceptions(headerFieldNames, row) {
    fields = splitFields(row);
    rowObj = zipRow(headerFieldNames, fields);
    if (rowObj === null) {
        return showError(new Error('Encountered a row with an unexpected number of items'));
    }

    rowObjWithDate = addDateStr(rowObj);
    if (rowObjWithDate === null) {
        return showError(new Error('Unable to parse date in row object'));
    }

    return rowToMessage(rowObj);
}

Как видите, появилось большое количество шаблонных выражений if. Код более многословный. И трудно следовать основной логике. Кроме того, значение null не особо много нам говорит. Мы на самом деле не знаем, почему предыдущий вызов функции не удался. Нам придётся гадать. Мы создаём сообщение об ошибке и вызываем showError(). Такой код грязнее и запутаннее.

Посмотрите ещё раз на версию с обработкой исключений. Она чётко разделяет удачный путь программы и код обработки исключений. Ветка try — это удачный путь, а catch — ошибки. Вся обработка исключений происходит в одном месте. И отдельные функции могут сообщать, почему они потерпели неудачу. В целом, это кажется довольно милым. Думаю, что большинство считает первый пример вполне подходящим. Зачем же другой подход?

Проблемы с обработкой исключений try...catch


Такой подход позволяет игнорировать эти досадные ошибки. К сожалению, try...catch делает свою работу слишком хорошо. Вы просто бросаете исключение и двигаетесь дальше. Мы можем словить его позже. И все намерены всегда ставить такие блоки, правда. Но не всегда очевидно, куда дальше идёт ошибка. И блок слишком легко забыть. И прежде чем вы это поймёте, ваше приложение аварийно завершает работу.

Кроме того, исключения загрязняют код. Мы не будем здесь подробно обсуждать функциональную чистоту. Но давайте рассмотрим один маленький аспект функциональной чистоты: ссылочную прозрачность. Ссылочно-прозрачная функция всегда возвращает один и тот же результат для конкретного входа. Но для функций с исключениями мы не можем такого сказать. В любой момент они могут выдать исключение вместо возврата значения. Это усложняет логику. Но что, если найти беспроигрышный вариант — чистый способ обработки ошибок?

Придумываем альтернативу


Чистые функции всегда возвращают значение (даже если это значение отсутствует). Поэтому наш код обработки ошибок должен предполагать, что мы всегда возвращаем значение. Итак, в качестве первой попытки, что делать, если при сбое мы вернули объект Error? То есть, где бы мы ни появилась ошибка, мы возвращаем такой объект. Это может выглядеть примерно так:

function processRowReturningErrors(headerFieldNames, row) {
    fields = splitFields(row);
    rowObj = zipRow(headerFieldNames, fields);
    if (rowObj instanceof Error) {
        return showError(rowObj);
    }

    rowObjWithDate = addDateStr(rowObj);
    if (rowObjWithDate instanceof Error) {
        return showError(rowObjWithDate);
    }

    return rowToMessage(rowObj);
}

Это не особое улучшение версии без исключений. Но так лучше. Мы перенесли ответственность за сообщения об ошибках обратно в отдельные функции. Но у нас остались все эти if'ы. Хорошо бы как-то инкапсулировать шаблон. Другими словами, если мы знаем, что у нас ошибка, не беспокоиться о выполнении остальной части кода.

Полиморфизм


Как это сделать? Это сложная проблема. Но её можно решить с помощью магии полиморфизма. Если вы раньше не сталкивались с полиморфизмом, не волнуйтесь. По сути это «предоставление единого интерфейса для сущностей разных типов» (Страуструп, Б. «Глоссарий C++ Бьёрна Страуструпа»). В JavaScript это означает, что мы создаём объекты с одинаково именованными методами и сигнатурами. Но разное поведение. Классический пример — ведение журнала приложений. Мы можем отправить наши журналы в разные места в зависимости от того, в какой среде находимся. Что, если мы создадим два объекта logger, например?

const consoleLogger = {
    log: function log(msg) {
        console.log('This is the console logger, logging:', msg);
    }
};

const ajaxLogger = {
    log: function log(msg) {
        return fetch('https://example.com/logger', {method: 'POST', body: msg});
    }
};

Оба объекта определяют лог-функцию, которая ожидает один строковый параметр. Но они ведут себя по-разному. Красота в том, что мы можем написать код, который вызывает .log(), при этом неважно, какой объект он использует. Это может быть consoleLogger или ajaxLogger. Всё работает в любом случае. Например, приведённый ниже код будет одинаково хорошо работать с любым объектом:

function log(logger, message) {
    logger.log(message);
}

Другой пример — метод .toString() для всех JS-объектов. Можем написать метод .toString() для любого класса, который создаём. Далее, можно создать два класса, которые по-разному реализуют метод .toString(). Назовём их Left и Right (чуть позже объясню названия).

class Left {
  constructor(val) {
    this._val = val;
  }
  toString() {
    const str = this._val.toString();
    return `Left(${str})`;
  }
}

class Right {
  constructor(val) {
    this._val = val;
  }
  toString() {
    const str = this._val.toString();
    return `Right(${str})`;
  }
}

Теперь создадим функцию, которая вызывает .toString() на этих двух объектах:

function trace(val) {
    console.log(val.toString());
    return val;
}

trace(new Left('Hello world'));
// ⦘ Left(Hello world)

trace(new Right('Hello world'));
// ⦘ Right(Hello world);

Не выдающийся код, я знаю. Но дело в том, что у нас два разных типа поведения, которые используют один и тот же интерфейс. Это полиморфизм. Но обратите внимание на кое-что интересное. Сколько операторов if мы использовали? Ноль. Ни одного. Мы создали два различных типа поведения без единого if-оператора. Возможно, нечто такое можно использовать для обработки ошибок…

Left и Right


Возвращаясь к нашей проблеме. Нужно определить удачный и неудачный путь для нашего кода. На удачном пути мы просто продолжаем спокойно запускать код, пока не произойдёт ошибка или мы не закончим. Если мы окажемся на неудачном пути, мы больше не будем пытаться запустить код. Мы могли бы назвать эти пути Happy и Sad, но постараемся следовать соглашениям об именах, которые используют другие языки программирования и библиотеки. Итак, назовём неудачный путь Left, а удачный — Right.

Создадим метод, который запускает функцию, если мы находимся на удачном пути, но игнорировать её на неудачном:

/**
 * Left represents the sad path.
 */
class Left {
    constructor(val) {
        this._val = val;
    }
    runFunctionOnlyOnHappyPath() {
        // Left is the sad path. Do nothing
    }
    toString() {
        const str = this._val.toString();
        return `Left(${str})`;
    }
}

/**
 * Right represents the happy path.
 */
class Right {
  constructor(val) {
    this._val = val;
  }
  runFunctionOnlyOnHappyPath(fn) {
    return fn(this._val);
  }
  toString() {
    const str = this._val.toString();
    return `Right(${str})`;
  }
}

Что-то вроде такого:

const leftHello  = new Left('Hello world');
const rightHello = new Right('Hello world');

leftHello.runFunctionOnlyOnHappyPath(trace);
// does nothing


rightHello.runFunctionOnlyOnHappyPath(trace);
// ⦘ Hello world
// ← "Hello world"

Трансляция


Мы приближаемся к чему-то полезному, но ещё не совсем. Наш метод .runFunctionOnlyOnHappyPath() возвращает свойство _val. Всё нормально, но слишком неудобно, если мы хотим запустить более одной функции. Почему? Потому что мы уже не знаем, находимся на удачном или неудачном пути. Информация исчезает, как только мы берём значение за пределами Left и Right. Итак, что мы можем сделать, так это вернуть путь Left или Right с новым _val внутри. И мы сократим имя, раз уж мы здесь. То, что мы делаем, — это трансляция функции из мира простых значений в мир Left и Right. Поэтому назовём метод map():

/**
 * Left represents the sad path.
 */
class Left {
    constructor(val) {
        this._val = val;
    }
    map() {
        // Left is the sad path
        // so we do nothing
        return this;
    }
    toString() {
        const str = this._val.toString();
        return `Left(${str})`;
    }
}

/**
 * Right represents the happy path
 */
class Right {
    constructor(val) {
        this._val = val;
    }
    map(fn) {
        return new Right(
            fn(this._val)
        );
    }
    toString() {
        const str = this._val.toString();
        return `Right(${str})`;
    }
}

Вставляем этот метод и используем Left или Right в свободном синтаксисе:

const leftHello        = new Left('Hello world');
const rightHello       = new Right('Hello world');
const helloToGreetings = str => str.replace(/Hello/, 'Greetings,');

leftHello.map(helloToGreetings).map(trace);
// Doesn't print any thing to the console
// ← Left(Hello world)

rightHello.map(helloToGreetings).map(trace);
// ⦘ Greetings, world
// ← Right(Greetings, world)

Мы создали два пути выполнения. Можем положить данные на удачный путь, вызвав new Right(), или на неудачный, вызвав new Left().


Каждый класс представляет путь: удачный или неудачный. Эту железнодорожную метафору я украл у Скотта Влащина

Если map сработал на удачном пути, едем по нему и обрабатываем данные. Если окажемся на неудачном, ничего не произойдёт. Просто продолжаем передавать значение дальше. Если бы мы, например, поместили Error на этом неудачном пути, то получилось бы что-то очень похожее на try…catch.


Используем .map() для перемещения по пути

По мере дальнейшего пути становится немного сложно всё время писать Left или Right, поэтому назовём эту комбинацию просто Either («либо»). Либо налево, либо направо.

Ярлыки для создания объектов Either


Итак, следующим шагом будет переписать наши примеры функций, чтобы они возвращали Either. Left для ошибки или Right для значения. Но прежде чем мы это сделаем, немного развлечёмся. Напишем пару ярлычков. Первый — это статический метод под названием .of(). Он всего лишь возвращает новый Left или Right. Код может выглядеть следующим образом:

Left.of = function of(x) {
    return new Left(x);
};

Right.of = function of(x) {
    return new Right(x);
};

Честно говоря, даже Left.of() и Right.of() утомительно писать. Поэтому я склоняюсь к ещё более коротким ярлыкам left() и right():

function left(x) {
    return Left.of(x);
}

function right(x) {
    return Right.of(x);
}

С этими ярлычками начнём переписывать функции приложения:

function zipRow(headerFields, fieldData) {
    const lengthMatch = (headerFields.length == fieldData.length);
    return (!lengthMatch)
        ? left(new Error("Row has an unexpected number of fields"))
        : right(_.zipObject(headerFields, fieldData));
}

function addDateStr(messageObj) {
    const errMsg = 'Unable to parse date stamp in message object';
    const months = [
        'January', 'February', 'March', 'April', 'May', 'June', 'July',
        'August', 'September', 'October', 'November', 'December'
    ];
    const d = new Date(messageObj.datestamp);
    if (isNaN(d)) { return left(new Error(errMsg));  }

    const datestr = `${d.getDate()} ${months[d.getMonth()]} ${d.getFullYear()}`;
    return right({datestr, ...messageObj});
}

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

После этого можем начать переработку основной функции, которая обрабатывает одну строку. Начнём с того, что поместим строку в Either с right(), а затем транслируем splitFields, чтобы её разделить:

function processRow(headerFields, row) {
    const fieldsEither   = right(row).map(splitFields);
   // …
}

Это работает просто отлично, но случается беда, если попытаться то же самое сделать с zipRow():

    function processRow(headerFields, row) {
        const fieldsEither   = right(row).map(splitFields);
        const rowObj         = fieldsEither.map(zipRow /* wait. this isn't right */);
        // ...
    }

Дело в том, что zipRow() ожидает два параметра. Но функции, которые мы передаём в .map(), получают только одно значение из свойства ._val. Ситуацию можно исправить с помощью каррированной версии zipRow(). Это может выглядеть примерно так:

function zipRow(headerFields) {
    return function zipRowWithHeaderFields(fieldData) {
        const lengthMatch = (headerFields.length == fieldData.length);
        return (!lengthMatch)
            ? left(new Error("Row has an unexpected number of fields"))
            : right(_.zipObject(headerFields, fieldData));
    };
}

Это небольшое изменение упрощает преобразование zipRow, поэтому будет хорошо работать с .map():

function processRow(headerFields, row) {
    const fieldsEither   = right(row).map(splitFields);
    const rowObj         = fieldsEither.map(zipRow(headerFields));
    // ... But now we have another problem ...
}

Join


Использовать .map() для запуска splitFields() — это нормально, поскольку .splitFields() не возвращает Either. Но когда приходится запускать zipRow(), возникает проблема, потому что он возвращает Either. Так что при использовании .map() мы в конечном итоге утыкаемся в Either внутри Either. Если пойти дальше, то застрянем, пока не запустим .map() внутри .map(). Это тоже не сработает. Нужен какой-то способ объединить эти вложенные Either. Так что напишем новый метод, который назовём .join():

/**
 *Left represents the sad path.
 */
class Left {
    constructor(val) {
        this._val = val;
    }
    map() {
        // Left is the sad path
        // so we do nothing
        return this;
    }
    join() {
        // On the sad path, we don't
        // do anything with join
        return this;
    }
    toString() {
        const str = this._val.toString();
        return `Left(${str})`;
    }
}

/**
 * Right represents the happy path
 */
class Right {
    constructor(val) {
        this._val = val;
    }
    map(fn) {
        return new Right(
            fn(this._val)
        );
    }
    join() {
        if ((this._val instanceof Left)
            || (this._val instanceof Right))
        {
            return this._val;
        }
        return this;
    }
    toString() {
        const str = this._val.toString();
        return `Right(${str})`;
    }
}

Теперь можем «распаковать» свои активы:

function processRow(headerFields, row) {
    const fieldsEither   = right(row).map(splitFields);
    const rowObj         = fieldsEither.map(zipRow(headerFields)).join();
    const rowObjWithDate = rowObj.map(addDateStr).join();
    // Slowly getting better... but what do we return?
}

Chain


Мы далеко продвинулись. Но приходится всё время помнить о вызове .join(), что раздражает. Однако у нас есть распространённый паттерн последовательного вызова .map() и .join(), так что создадим для него метод быстрого доступа. Назовём его chain() (цепь), потому что он связывает вместе функции, которые возвращают Left или Right.

/**
 *Left represents the sad path.
 */
class Left {
    constructor(val) {
        this._val = val;
    }
    map() {
        // Left is the sad path
        // so we do nothing
        return this;
    }
    join() {
        // On the sad path, we don't
        // do anything with join
        return this;
    }
    chain() {
        // Boring sad path,
        // do nothing.
        return this;
    }
    toString() {
        const str = this._val.toString();
        return `Left(${str})`;
    }
}

/**
 * Right represents the happy path
 */
class Right {
    constructor(val) {
        this._val = val;
    }
    map(fn) {
        return new Right(
            fn(this._val)
        );
    }
    join() {
        if ((this._val instanceof Left)
            || (this._val instanceof Right)) {
            return this._val;
        }
        return this;
    }
    chain(fn) {
        return fn(this._val);
    }
    toString() {
        const str = this._val.toString();
        return `Right(${str})`;
    }
}

Возвращаясь к аналогии с железнодорожными путями .chain() переключает рельсы, если мы сталкиваемся с ошибкой. Впрочем, это проще показать на диаграмме.


При возникновении ошибки метод .chain() позволяет переключиться на левый путь. Обратите внимание, что переключатели работают только в одну сторону

Код стал немного чище:

function processRow(headerFields, row) {
    const fieldsEither   = right(row).map(splitFields);
    const rowObj         = fieldsEither.chain(zipRow(headerFields));
    const rowObjWithDate = rowObj.chain(addDateStr);
    // Slowly getting better... but what do we return?
}

Сделать что-то со значениями


Рефакторинг функции processRow() почти завершён. Но что происходит, когда мы возвращаем значение? В конце концов, мы хотим предпринять различные действия в зависимости от того, какая у нас ситуация: Left или Right. Поэтому напишем функцию, которая будет принимать соответствующие меры:

function either(leftFunc, rightFunc, e) {
    return (e instanceof Left) ? leftFunc(e._val) : rightFunc(e._val);
}

Я схитрил и использовал внутренние значения объектов Left или Right. Но сделайте вид, что вы этого не заметили. Теперь можно завершить нашу функцию:

function processRow(headerFields, row) {
    const fieldsEither   = right(row).map(splitFields);
    const rowObj         = fieldsEither.chain(zipRow(headerFields));
    const rowObjWithDate = rowObj.chain(addDateStr);
    return either(showError, rowToMessage, rowObjWithDate);
}

И если мы чувствуем себя особенно умными, то можем опять применять свободный синтаксис:

function processRow(headerFields, row) {
    const rowObjWithDate = right(row)
        .map(splitFields)
        .chain(zipRow(headerFields))
        .chain(addDateStr);
    return either(showError, rowToMessage, rowObjWithDate);
}

Обе версии довольно красивые. Никаких конструкций try...catch. И никаких if-операторов в функции верхнего уровня. Если есть проблема с какой-то конкретной строкой, мы просто показываем сообщение об ошибке в конце. И обратите внимание, что в processRow() мы упоминаем Left или Right единственный раз в самом начале, когда вызываем right(). В остальном используются только методы .map() и .chain() для применения следующей функции.

ap и lift


Это выглядит хорошо, но осталось рассмотреть один последний сценарий. Придерживаясь нашего примера, давайте посмотрим, как можно обрабатывать все данные CSV, а не только каждую строку по отдельности. Нам понадобится вспомогательная функция (хелпер) или три:

function splitCSVToRows(csvData) {
    // There should always be a header row... so if there's no
    // newline character, something is wrong.
    return (csvData.indexOf('\n') < 0)
        ? left('No header row found in CSV data')
        : right(csvData.split('\n'));
}

function processRows(headerFields, dataRows) {
    // Note this is Array map, not Either map.
    return dataRows.map(row => processRow(headerFields, row));
}

function showMessages(messages) {
    return `<ul class="Messages">${messages.join('\n')}</ul>`;
}

Итак, у нас появился хелпер, который разбивает CSV на строки. И мы возвращаемся к варианту с Either. Теперь можно использовать .map() и некоторые lodash-функции для выделения строки заголовка из строк данных. Но мы оказываемся в интересной ситуации…

function csvToMessages(csvData) {
    const csvRows      = splitCSVToRows(csvData);
    const headerFields = csvRows.map(_.head).map(splitFields);
    const dataRows     = csvRows.map(_.tail);
    // What’s next?
}

У нас есть поля заголовка и строки данных, готовые к отображению с помощью processRows(). Но headerFields и dataRows обёрнуты в Either. Нужен какой-то способ преобразовать processRows() в функцию, которая работает с Either. Для начала проведём каррирование processRows.

function processRows(headerFields) {
    return function processRowsWithHeaderFields(dataRows) {
        // Note this is Array map, not Either map.
        return dataRows.map(row => processRow(headerFields, row));
    };
}

Теперь всё готово для эксперимента. У нас headerFields, который представляет собой Either, обёрнутый вокруг массива. Что будет, если мы возьмём headerFields и вызовем на нём .map() с processRows()?

function csvToMessages(csvData) {
    const csvRows      = splitCSVToRows(csvData);
    const headerFields = csvRows.map(_.head).map(splitFields);
    const dataRows     = csvRows.map(_.tail);

    // How will we pass headerFields and dataRows to
    // processRows() ?
    const funcInEither = headerFields.map(processRows);
}

С помощью .map() здесь вызывается внешняя функция processRows(), но не внутренняя. Другими словами, processRows() возвращает функцию. И поскольку это .map(), мы всё ещё получаем обратно Either. Таким образом, в итоге получается функция внутри Either, которая получила название funcInEither. Она принимает массив строк и возвращает массив других строк. Нужно каким-то образом взять эту функцию и вызвать её со значением внутри dataRows. Для этого нужно добавить ещё один метод в наши классы Left и Right. Назовём его .ap() в соответствии со стандартом.

Как обычно, метод ничего не делает на треке Left:

    // In Left (the sad path)
    ap() {
        return this;
    }

А для класса Right мы ожидаем другой Either с функцией:

    // In Right (the happy path)
    ap(otherEither) {
        const functionToRun = otherEither._val;
        return this.map(functionToRun);
    }

Теперь можем завершить нашу основную функцию:

    function csvToMessages(csvData) {
        const csvRows      = splitCSVToRows(csvData);
        const headerFields = csvRows.map(_.head).map(splitFields);
        const dataRows     = csvRows.map(_.tail);
        const funcInEither = headerFields.map(processRows);
        const messagesArr  = dataRows.ap(funcInEither);
        return either(showError, showMessages, messagesArr);
    }

Суть метода .ap() сразу немного понять (спецификации Fantasy Land путано его описывают, а в большинстве других языков метод используется наоборот). Если описать её проще, то вы говорите: «У меня есть функция, которая обычно принимает два простых значения. Я хочу превратить её в функцию, которая принимает два Either». При наличии .ap() мы можем написать функцию, которая будет делать именно это. Назовём её liftA2(), опять же в соответствии со стандартным названием. Она берёт простую функцию, ожидающую два аргумента, и «поднимает» (lift) её для работы с «аппликативами». (это объекты, которые содержат и метод .ap(), и метод .of()). Так, liftA2 является сокращением для «аппликатив lift, два параметра».

Таким образом, функция liftA2 может выглядеть примерно так:

function liftA2(func) {
    return function runApplicativeFunc(a, b) {
        return b.ap(a.map(func));
    };
}

Наша функция верхнего уровня будет использовать его следующим образом:

function csvToMessages(csvData) {
    const csvRows      = splitCSVToRows(csvData);
    const headerFields = csvRows.map(_.head).map(splitFields);
    const dataRows     = csvRows.map(_.tail);
    const processRowsA = liftA2(processRows);
    const messagesArr  = processRowsA(headerFields, dataRows);
    return either(showError, showMessages, messagesArr);
}

Код на CodePen.

Правда? Это всё?


Вы спросите, чем это лучше простых исключений? Не кажется ли мне, что это слишком сложный способ решения простой проблемы? Давайте сначала подумаем, почему нам нравятся исключения. Если бы не было исключений, пришлось бы повсюду писать много if-операторов. Мы будем вечно писать код по принципу «если последнее сработает, продолжай, иначе обработай ошибку». И мы должны обрабатывать эти ошибки во всём коде. Это затрудняет понимание того, что происходит. Исключения позволяют выйти из программы, если что-то пошло не так. Поэтому не нужно писать все эти if'ы. Можно сосредоточиться на удачном пути выполнения.

Но есть одна загвоздка. Исключения слишком многое скрывают. Когда вы создаёте исключение, вы переносите проблему обработки ошибки на какую-то другую функцию. Слишком легко игнорировать исключение, которое выплывет до самого верхнего уровня. Приятная сторона Either в том, что он позволяет выпрыгнуть из основного потока программы, словно с исключением. И работает честно. Вы получаете или Right, или Left. Вы не можете притвориться, что вариант Left невозможен. В конце концов, вы должны вытащить значение вызовом вроде either().

Я знаю, это звучит, как некая сложность. Но взгляните на код, который мы написали (не классы, а функции, которые их используют). Там не так много кода обработки исключений. Его почти нет, за исключением вызова either() в конце csvToMessages() и processRow(). В том-то всё и дело. С Either у вас чистая обработка ошибок, которую невозможно случайно забыть. Без Either топаете через код и везде добавляете отступы.

Это не означает, что никогда нельзя использовать try...catch. Иногда это правильный инструмент, и это нормально. Но это не единственный инструмент. Either даёт некоторые преимущества, которых нет у try...catch. Так что дайте шанс этой монаде. Даже если поначалу сложно, думаю, вам понравится. Только, пожалуйста, не используйте реализацию из этой статьи. Попробуйте одну из известных библиотек, таких как Crocks, Sanctuary, Folktale или Monet. Они лучше обслуживаются. И здесь я для простоты кое-что пропустил.

Дополнительные ресурсы