Обложка статьи «Используем приёмы из функционального программирования, чтобы улучшить свой код на JavaScript»

Используем приёмы из функционального программирования, чтобы улучшить свой код на JavaScript

Перевод статьи «Here’s How Not to Suck at JavaScript»

Рассказывает Илья Суздальницкий, senior full-stack-разработчик

JavaScript — самый распространённый язык программирования в мире. Простота и обилие учебных ресурсов делают его доступным для начинающих. Множество талантливых разработчиков делают этот язык привлекательным для компаний любого размера. Огромная экосистема инструментов и библиотек — настоящий дар для производительности разработчиков. Когда один язык позволяет управлять и фронтендом и бэкендом, а один и тот же набор навыков может использоваться во всём стеке, это становится огромный преимуществом.

Ядерная сила JavaScript

JavaScript предоставляет множество инструментов и это очень хорошо. Но горькая правда заключается в том, что он практически не налагает никаких ограничений на разработчика. Если посадить за JavaScript неопытного разработчика, это будет равносильно тому, как дать двухлетнему ребёнку коробку спичек и канистру бензина.

Сила JavaScript — ядерная, и её можно направить на снабжение городов электричеством или на разрушение. Создать что-то, что работает на JavaScript, легко. А вот создать программное обеспечение, одновременно надёжное и обслуживаемое, — нет.

Надёжность кода

При строительстве плотины инженеры в первую очередь заботятся о надёжности. Возведение плотины без каких-либо планов или мер безопасности опасно. То же самое относится к строительству мостов, самолётов, автомобилей. Ни одна из новых «плюшек» не имеет значения, если автомобиль небезопасен и ненадёжен — ни лошадиные силы, ни громкость выхлопа, ни тип кожи, используемой в интерьере.

Точно так же цель каждого разработчика — написать надёжное программное обеспечение. Ничто другое не имеет значения, если код глючит. Самый лучший подход к написанию такого надёжного кода — простота. Простота противоположна сложности. Следовательно, первая и главная задача разработчиков должна заключаться в упрощении кода.

Опытного разработчика от неопытного отличает умение писать надёжный софт. Надёжность также включает в себя поддержку — только поддерживаемая кодовая база может быть надёжной. Вся эта статья посвящена написанию именно надёжного кода.

Хоть я и сторонник функционального программирования, я не собираюсь ничего проповедовать. Вместо этого я собираюсь взять несколько полезных понятий из функционального программирования и продемонстрировать, как их можно применять в JavaScript.

Действительно ли важна надёжность программного обеспечения? Это решать вам. Некоторые утверждают, что достаточно того, чтобы клиенты могли продолжать использовать софт. Я не согласен. На самом деле ничто иное не имеет значения, если ПО ненадёжно и его трудно поддерживать. Кто купил бы машину, которая ломается и разгоняется случайным образом? Сколько людей будет использовать телефон, который теряет связь несколько раз в день и перезагружается? Вероятно, не слишком много. Программное обеспечение не сильно отличается в этом плане.

Недостаток ОЗУ

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

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

Это можно прямо перевести на программирование — простой код потребляет меньше умственных ресурсов, делает нас более эффективными и в результате приводит к более надёжному программному обеспечению. Эта статья вместе с некоторыми из доступных инструментов JavaScript поможет вам достичь этого.

Заметка для новичков

В этой статье будут использоваться функции ES6. Убедитесь, что вы знакомы с ES6, прежде чем читать. Как краткое напоминание:

// ---------------------------------------------
// Лямбда функция (жирная стрелка)
// ---------------------------------------------

const doStuff = (a, b, c) => {...}

// То же самое что:
function doStuff(a, b, c) {
  ...
}
  
// ---------------------------------------------  
// Деструктуризация объекта
// ---------------------------------------------
  
const doStuff = ({a, b, c}) => {
  console.log(a);
}
  
// То же самое что:
const doStuff = (params) => {
  const {a, b, c} = params;
  
  console.log(a);
} 
                              
// То же самое что:                             
const doStuff = (params) => {  
  console.log(params.a);
}
                              
// ---------------------------------------------                            
// Деструктуризация массива
// ---------------------------------------------

const [a, b] = [1, 2];
                              
// То же самое что:
const array = [1, 2];
const a = array[0];
const b = array[1];

Инструментарий

Одна из самых сильных сторон JavaScript — доступный инструментарий. Ни один другой язык программирования не может похвастаться доступом к такой большой экосистеме инструментов и библиотек.

И этими инструментами нужно пользоваться, особенно ESLint — инструмент для статического анализа кода. Это самый важный инструмент, который позволяет находить потенциальные проблемы в кодовой базе и обеспечивать её высокое качество. Самое приятное, что linting — это полностью автоматизированный процесс, который можно использовать, чтобы низкокачественный код не попал в базу.

Многие практически не используют ESLint. Они просто включают предварительно созданную конфигурацию вроде eslint-config-airbnb и думают, что всё готово. Это допустимый подход, но он едва затрагивает то, что может предложить ESLint. JavaScript — язык без ограничений. Неправильная настройка линтинга может иметь далеко идущие последствия.

Знать все возможности языка полезно, но опытный разработчик знает, какие функции не следует использовать. JavaScript — старый язык, он поставляется с большим количеством багажа, который он пытается реализовывать. Важно уметь отличать хорошие функции от плохих.

Настройка ESLint

Я бы порекомендовал ознакомиться с указаниями по очереди, а также включить правила ESLint в ваш проект. Изначально настройте их как предупреждение (warn). А когда вам будет удобно, можете преобразовать некоторые указания как ошибки (error).

В корневом каталоге проекта запустите:

npm i -D eslint
npm i -D eslint-plugin-fp

Затем создайте там же файл .eslintrc.yml:

env:
  es6: true
 
plugins:
  fp
  
rules:
  # Правила будут здесь

Если вы используете IDE вроде VSCode, обязательно установите плагин ESLint.

Также можно запустить ESLint вручную из командной строки:

npx eslint .

Важность рефакторинга

Рефакторинг — процесс упрощения существующего кода. При правильном применении рефакторинг становится лучшим оружием против страшного монстра — технического долга. Без постоянного рефакторинга технический долг будет накапливаться, что сделает разработчиков медленнее и разочарованнее. Технический долг является одной из основных причин выгорания разработчиков.

Рефакторинг сводится к процессу очистки существующего кода с одновременной проверкой правильности его работы. Это считается хорошей практикой в разработке программного обеспечения и должно быть нормальной частью процесса разработки в любой здоровой организации.

В качестве предостережения перед проведением рефакторинга лучше всего покрыть код автоматическими тестами. Легко непреднамеренно нарушить существующую функциональность, а комплексный набор тестов — отличный способ ограничить любые потенциальные проблемы.

Самый большой источник сложности

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

Длинные файлы

Признаем, люди ленивы. Лень — это краткосрочная стратегия выживания, заложенная в мозгу. Это помогает сохранять энергию, избегая вещей, которые не являются критическими для выживания.

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

Это можно решить очень легко. Просто разбейте большие файлы на более мелкие и более детализированные модули.

Предлагаемая конфигурация ESLint:

rules:
  max-lines:
  - warn
  - 200

Длинные функции

Другими важными источниками сложности являются длинные и сложные функции. Обычно они имеют слишком много обязанностей и их сложно проверить.

Рассмотрим фрагмент кода express.js для обновления записи в блоге:

router.put('/api/blog/posts/:id', (req, res) => {
  if (!req.body.title) {
    return res.status(400).json({
      error: 'title is required',
    });
  }
  
  if (!req.body.text) {
    return res.status(400).json({
      error: 'text is required',
    });
  }
  
  const postId = parseInt(req.params.id);

  let blogPost;
  let postIndex;
  blogPosts.forEach((post, i) => {
    if (post.id === postId) {
      blogPost = post;
      postIndex = i;
    }
  });

  if (!blogPost) {
    return res.status(404).json({
      error: 'post not found',
    });
  }

  const updatedBlogPost = {
    id: postId,
    title: req.body.title,
    text: req.body.text
  };

  blogPosts.splice(postIndex, 1, updatedBlogPost);

  return res.json({
    updatedBlogPost,
  });
});

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

Это может быть преобразовано в несколько функций меньшего объёма. Полученный обработчик маршрута может выглядеть примерно так:

router.put("/api/blog/posts/:id", (req, res) => {
  const { error: validationError } = validateInput(req.body);
  if (validationError) return errorResponse(res, validationError, 400);

  const { blogPost } = findBlogPost(blogPosts, req.params.id);

  const { error: postError } = validateBlogPost(blogPost);
  if (postError) return errorResponse(res, postError, 404);

  const updatedBlogPost = buildUpdatedBlogPost(req.body);

  updateBlogPosts(blogPosts, updatedBlogPost);
  
  return res.json({updatedBlogPost});
});

Предлагаемая конфигурация ESLint:

rules:
  max-lines-per-function:
  - warn
  - 20

Сложные функции

Сложные функции идут рука об руку с длинными — более длинные функции всегда сложнее, чем короткие. Некоторые вещи делают функции более сложными. То, что можно исправить среди прочего, — это вложенные колбеки (callback) и высокая цикломатическая сложность.

Вложенные колбеки часто приводят к колбек-аду (callback hell). Это может быть легко исправлено использованием промисов (promise) и асинхронных функций async() и await().

Вот пример функции с глубоко вложенными колбеками:

fs.readdir(source, function (err, files) {
  if (err) {
    console.error('Error finding files: ' + err)
  } else {
    files.forEach(function (filename, fileIndex) {
      gm(source + filename).size(function (err, values) {
        if (err) {
          console.error('Error identifying file size: ' + err)
        } else {
          aspect = (values.width / values.height)
          widths.forEach(function (width, widthIndex) {
            height = Math.round(width / aspect)
            this.resize(width, height).write(dest + 'w' + width + '_' + filename, function(err) {
              if (err) console.error('Error writing file: ' + err)
            })
          }.bind(this))
        }
      })
    })
  }
})

Цикломатическая сложность

Ещё функции перегружает цикломатическая сложность. Это относится к количеству операторов (логических) в любой функции: операторы if, циклы и switch-утверждения. Такие функции трудно воспринимать. Их использование должно быть ограничено. Вот пример:

if (conditionA) {
  if (conditionB) {
    while (conditionC) {
      if (conditionD && conditionE || conditionF) {
        ...
      }
    }
  }
}

Предлагаемая конфигурация ESLint:

rules:
  complexity:
  - warn
  - 5
  
  max-nested-callbacks:
  - warn
  - 2
  max-depth:
  - warn
  - 3

Декларативный код — другой важный способ уменьшить объём кода, но об этом позже.

Изменчивое состояние

Состояние — это любые временные данные, хранящиеся в памяти, например переменные или поля внутри объектов. Состояние само по себе довольно безобидно. Изменчивое состояние является одним из источников сложности программного обеспечения, особенно в сочетании с объектной ориентацией (подробнее об этом позже).

Ограничения человеческого мозга

Почему изменчивое состояние такая большая проблема? Наш мозг плохо работает с состоянием, поскольку мы можем хранить в рабочей памяти одновременно только около пяти элементов. Гораздо проще рассуждать о куске кода, если вы думаете только о том, что делает код, а не о том, какие переменные он изменяет вокруг кодовой базы.

Программирование с изменяемым состоянием — это акт умственного жонглирования. Жонглировать двумя шарами довольно просто. Тремя или больше — гораздо сложнее. С написанием кода так же. Я стал намного продуктивнее и мой код стал намного надёжнее, как только я отбросил изменчивое состояние.

Проблемы с изменчивым состоянием

Посмотрим на практике, как изменчивость может сделать код проблематичным:

const increasePrice = (item, increaseBy) => {
  // Никогда так не делайте
  item.price += increaseBy;

  return item;
};

const oldItem = { price: 10 };

const newItem = increasePrice(oldItem, 3);

// Выводит «newItem.price 13»
console.log('newItem.price', newItem.price);

// Выводит «oldItem.price 13»
// Неожиданно?
console.log('oldItem.price', oldItem.price);

Ошибка очень тонкая. Изменяя аргументы функции, вы случайно изменили цену исходного элемента. Предполагалось, что её значение равно 10, но на самом деле значение изменилось на 13.

Этого можно избежать, создавая и возвращая новый объект (неизменность):

const increasePrice = (item, increaseBy) => ({
  ...item,
  price: item.price + increaseBy
});

const oldItem = { price: 10 };

const newItem = increasePrice(oldItem, 3);

// Выводит «newItem.price 13»
console.log('newItem.price', newItem.price);

// Выводит «oldItem.price 10»
// Как и ожидалось
console.log('oldItem.price', oldItem.price);

Следует отметить, что копирование с использованием ES6-оператора «spread» делает поверхностную, а не глубокую копию — она не будет копировать ни одно из вложенных свойств. Например, если у товара выше есть что-то вроде item.seller.id, seller нового товара всё равно будет ссылаться на старый товар. Другие более надёжные альтернативы для работы с неизменяемым состоянием в JavaScript включают в себя immutable.js и Ramda lenses.

Предлагаемая конфигурация ESLint:

rules:
  fp/no-mutation: warn
  no-param-reassign: warn

Не используйте push для массивов

Те же проблемы присущи изменениям массива с использованием таких методов, как push:

const a = ['apple', 'orange'];
const b = a;

a.push('microsoft')

// ['apple', 'orange', 'microsoft']
console.log(a);

// ['apple', 'orange', 'microsoft']
// Неожиданно?
console.log(b);

Вы могли ожидать, что массив b останется прежним. Эту ошибку можно легко избежать, если создать новый массив вместо вызова push.

const newArray = [...a, 'microsoft'];

Недетерминизм

Недетерминизм — это причудливый термин, который описывает просто неспособность программ производить одинаковый результат при одинаковых входных данных. Вы можете думать, что 2 + 2 == 4, но это не всегда так с недетерминированными программами. Два плюс два в большинстве случаев равно четырём, но иногда и трём, и пяти, и даже 1004.

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

Неизменность

Использование неизменности — очень хорошая альтернативная практика по отношению к недетерменизму. Некоторые могут не согласиться и сказать, что изменчивое состояние великолепно. Одно я могу сказать наверняка — изменчивость не делает кодовую базу более надёжной.

Предлагаемая конфигурация ESLint:

rules:
  fp/no-mutating-assign: warn
  fp/no-mutating-methods: warn
  fp/no-mutation: warn

Как избежать ключевого слова Let

Все знают, что не нужно использовать ключевое слово var для объявления переменных в JavaScript. Но вы точно будете удивлены, узнав, что ключевого слова let также следует избегать. Переменные, объявленные с помощью него, могут быть переназначены. Это усложняет анализ кода. При его использовании необходимо учитывать все побочные эффекты и возможные пограничные случаи. Можно случайно присвоить переменной неверное значение и потратить время на отладку.

Это особенно относится к модульному тестированию. Совместное использование изменяемого состояния между несколькими тестами — это путь к катастрофе, учитывая, что большинство раннеров тестов запускают тесты параллельно.

Альтернативой использования let является ключевое слово const. Хоть оно и не гарантирует неизменности, оно упрощает анализ кода, запрещая переназначения. Вам на самом деле не нужен let — в большинстве случаев код, который переназначает значения переменных, может быть извлечён в отдельную функцию. Рассмотрим пример:

let discount;

if (isLoggedIn) {
  if (cartTotal > 100  && !isFriday) {
    discount = 30;
  } else if (!isValuedCustomer) {
    discount = 20;
  } else {
    discount = 10;
  }
} else {
  discount = 0;
}

Тот же пример, извлечённый в функцию:

const getDiscount = ({isLoggedIn, cartTotal, isValuedCustomer}) => {
  if (!isLoggedIn) {
    return 0;
  }

  if (cartTotal > 100  && !isFriday()) {
    return 30;
  }
  
  if (!isValuedCustomer) {
    return 20;
  }
  
  return 10;
}

Кодить без let поначалу может показаться трудным, но это сделает ваш код менее сложным и более читабельным.

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

Предлагаемая конфигурация ESLint:

rules:
  fp/no-let: warn

Объектно-ориентированное программирование

Даже не вдаваясь в подробности недостатков ООП, я твёрдо убеждён, что современное ООП является одним из крупнейших источников сложности программного обеспечения. Есть успешные проекты, построенные с использованием ООП, однако это не означает, что такие проекты не страдают от необоснованной сложности.

ООП в JavaScript является особенно плохой идеей, поскольку в языке отсутствуют такие вещи, как статическая проверка типов, обобщения и интерфейсы. Ключевое слово this в JavaScript довольно ненадёжно.

Сложность ООП является хорошим упражнением для мозга. Но если целью является написание надёжного ПО, необходимо стремиться к уменьшению сложности. В идеале это означает избегать ООП. Если вы хотите узнать больше, ознакомьтесь со статьей «Object-Oriented Programming — The Trillion Dollar Disaster» (Прим. ред. Читайте перевод на нашем сайте).

Ключевое слово this

Поведение ключевого слова this последовательно непоследовательное. Оно привередливо и может означать совершенно разные вещи в разных контекстах. Его поведение зависит даже от того, кто вызвал данную функцию. Использование этого ключевого слова часто приводит к неуловимым и странным ошибкам, которые трудно отладить.

Это может быть забавный вопрос в интервью для кандидатов на работу, но на самом деле знание этого ключевого слова ни о чём не говорит. Только то, что кандидат потратил несколько часов на изучение наиболее распространённых вопросов интервью на JavaScript.

Что бы я ответил, если бы мне дали хитрый кусок кода с ключевым словом this? Как канадец, я бы сказал: «Простите… я не знаю». Реальный код не должен быть подвержен ошибкам. Он должен быть читаемым, бесхитростным. Слово this — очевидный недостаток архитектуры языка, и его не следует использовать.

Предлагаемая конфигурация ESLint:

rules:
  fp/no-this: warn

Декларативный код

Если у вас уже есть небольшой опыт программирования, скорее всего, вы использовали императивный стиль. Он описывает набор шагов для достижения желаемого результата. Декларативный стиль описывает желаемый результат, а не конкретные инструкции. Некоторые примеры часто используемых декларативных языков — SQL, HTML и даже JSX в React.

Базе данных не сообщаются точные шаги, как получить данные. Вместо этого используется SQL для описания, что нужно получить из базы.

SELECT * FROM Users WHERE Country='USA';

Это примерно может быть представлено в императивном JavaScript:

let user = null;

for (const u of users) {
  if (u.country === 'USA') {
    user = u;
    break;
  }
}

Или в декларативном JavaScript, используя экспериментальный оператор конвейера:

import { filter, first } from 'lodash/fp';

const filterByCountry =
  country => filter( user => user.country === country );

const user =
  users
  |> filterByCountry('USA')
  |> first;

Предпочтение выражений над операторами

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

Наиболее часто используемые операторы: if, return, switch, for, while.

Рассмотрим простой пример:

const calculateStuff = input => {
  if (input.x) {
    return superCalculator(input.x); 
  }
  
  return dumbCalculator(input.y);
};

Это может быть легко переписано как тройное выражение (которое является декларативным):

const calculateStuff = input => {
  return input.x
          ? superCalculator(input.x)
          : dumbCalculator(input.y);
};

И если return является единственным оператором в лямбда-функции, JavaScript позволяет также полностью избавиться и от лямбда-выражения:

const calculateStuff = input =>
  input.x ? superCalculator(input.x) : dumbCalculator(input.y);

Тело функции было сокращено с пяти строк кода до двух.

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

Выражения можно безопасно переупорядочивать, не вызывая побочных эффектов, и их легко распараллелить.

Декларативное программирование требует усилий

Декларативное программирование не может быть изучено за одну ночь. Особенно учитывая, что большинство людей в основном обучалось императивному программированию. Декларативное требует дисциплины и умения мыслить совершенно по-новому. Хороший первый шаг для изучения декларативного программирования — научиться писать код без изменяемого состояния и без использования ключевого слова let. Можно сказать одно наверняка: если вы попробуете декларативное программирование, вы будете удивлены, насколько элегантным станет ваш код.

Предлагаемая конфигурация ESLint:

rules:
  fp/no-let: warn
  fp/no-loops: warn
  fp/no-mutating-assign: warn
  fp/no-mutating-methods: warn
  fp/no-mutation: warn
  fp/no-delete: warn

Избегайте передачи нескольких параметров в функции

JavaScript не является статически типизированным языком, и нет способа гарантировать, что функция вызывается с правильными и ожидаемыми параметрами. ES6 привносит множество замечательных функций. В том числе деструктурирование объекта, которое также можно использовать для аргументов функций.

Считаете ли вы следующий фрагмент кода интуитивно понятным? Можете сразу сказать, какие у него параметры?

const total = computeShoppingCartTotal(itemList, 10.0, 'USD');

Что насчёт следующего примера?

const computeShoppingCartTotal = ({ itemList, discount, currency }) => {...};

const total = computeShoppingCartTotal({ itemList, discount: 10.0, currency: 'USD' });

Скорее всего, последний гораздо удобнее для чтения, чем первый. Это особенно относится к вызовам функций из другого модуля. Порядок аргументов не имеет значения, когда они используются объектом, и это хорошее преимущество.

Предлагаемая конфигурация ESLint:

rules:
  max-params:
  - warn
  - 2

Старайтесь возвращать объекты из функций

Что следующий фрагмент кода может рассказать о сигнатуре функции? Что она возвращает: объект пользователя, идентификатор пользователя, статус операции? Трудно сказать, не понимая контекста.

const result = saveUser(...);

Возвращение объекта из функции делает намерение разработчика ясным. Код становится значительно более читабельным:

const { user, status } = saveUser(...);

...

const saveUser = user => {
   ...

   return {
     user: savedUser,
     status: "ok"
   };
};

Управление потоком выполнения с исключениями

Вам не захочется наблюдать множество внутренних ошибок сервера при вводе неверных данных в форму. Как насчёт работы с API, которые не дают никаких подробностей, а вместо этого выдают ошибку «500»? Думаю, многие сталкивались с такими проблемами. И опыт был далеко не приятным.

Хоть многих и учат генерировать исключения, когда происходит что-то непредвиденное, это не лучший способ обработки ошибок. Рассмотрим, почему.

Исключения нарушают безопасность типов

Исключения нарушают безопасность типов даже в статически типизированных языках. Согласно своей сигнатуре, функция fetchUser(id: number): User должна вернуть пользователя. Ничто в сигнатуре функции не говорит о том, что будет сгенерировано исключение, если пользователь не может быть найден. Если ожидается исключение, то более подходящей сигнатурой функции будет: fetchUser(...): User|throws UserNotFoundError. Конечно, такой синтаксис недопустим независимо от языка.

Анализ программ, которые генерируют исключения, становится сложным. Никто никогда не знает, будет ли функция генерировать исключение. Можно обернуть каждый вызов функции в блок try/catch, но это непрактично и значительно ухудшит читаемость кода.

Исключения нарушают композицию функций

Исключения делают практически невозможным использование композиции функций. В следующем примере сервер вернёт внутреннюю ошибку сервера («500»), если одна из публикаций в блоге не найдена.

const fetchBlogPost = id => {
  const post = api.fetch(`/api/post/${id}`);

  if (!post) throw new Error(`Post with id ${id} not found`);

  return post;
};

const html = postIds |> map(fetchBlogPost) |> renderHTMLTemplate;

Что, если одно из сообщений было удалено, но пользователь всё ещё пытается получить доступ к сообщению из-за какого-то непонятного бага? Это значительно ухудшит пользовательский опыт.

Кортежи как альтернативный способ обработки ошибок

Способ обработки ошибок состоит в возврате кортежа, содержащего результат и ошибку, вместо генерирования исключения. JavaScript не поддерживает кортежи, но их можно легко эмулировать, используя массив из двух значений в виде [error, result]. Кстати, это также стандартный метод обработки ошибок в Go:

const fetchBlogPost = id => {
  const post = api.fetch(`/api/post/${id}`);

  return post
      // null для ошибки, если пост был найден
    ?  [null, post]
      // null для результата, если пост не был найден
    :  [`Post with id ${id} not found`, null];
};

const blogPosts = postIds |> map(fetchBlogPost);

const errors =
  blogPosts
  |> filter(([err]) => !!err)  // Сохраняем только элементы с ошибками
  |> map(([err]) => err); // Деструктурируем кортеж и возвращаем ошибку

const html =
  blogPosts
  |> filter(([err]) => !err)  // Сохраняем только элементы без ошибок
  |> map(([_, result]) => result)  // Деструктурируем кортеж и возвращаем результат
  |> renderHTML;

Иногда исключения хороши

Исключения всё ещё занимают своё место в кодовой базе. Вы должны задать себе вопрос: хотите ли вы, чтобы ваша программа аварийно завершилась? Любое брошенное исключение может уронить весь процесс. Даже если вы думаете, что тщательно рассмотрели все потенциальные пограничные случаи, исключения всё же являются небезопасными и приведут к аварийному завершению программы в будущем. Выбрасывайте исключения только в том случае, если вы действительно намерены вывести программу из строя. Например, из-за ошибки разработчика или сбоя соединения с базой данных.

Исключения так называются не просто так. Их следует использовать только тогда, когда произошло что-то исключительное, и у программы нет другого выбора, кроме как аварийно завершиться. Бросать и перехватывать исключения — не очень хороший способ контролировать поток выполнения. Прибегать к выбрасыванию исключений следует только в случае неисправимых ошибок. Неверный пользовательский ввод, кстати, к таковым не относится.

Позвольте коду аварийно завершиться — избегайте перехвата исключения

Окончательное правило обработки ошибок — избегайте перехвата исключений. Всё правильно, вам разрешено выдавать ошибки, если вы намерены аварийно завершить работу программы. Но вы никогда не должны отлавливать такие ошибки. Фактически, это подход, рекомендуемый функциональными языками, такими как Haskell и Elixir.

Единственным исключением из этого правила является использование сторонних API. Даже тогда лучше использовать вспомогательную функцию, которая оборачивает основную функцию, возвращающую кортеж [error, result]. Для этого вы можете использовать такие инструменты, как Saferr.

Задайтесь вопросом — кто несёт ответственность за ошибку? Если это пользователь, то ошибка должна быть обработана изящно. Пользователю необходимо показать приятное сообщение вместо внутренней ошибки «500».

К сожалению, в ESLint нет правила no-try-catch. Его ближайший сосед — no-throw. Убедитесь, что вы отбрасываете ошибки ответственно, в исключительных случаях, когда вы ожидаете сбой программы.

Предлагаемая конфигурация ESLint:

rules:
  fp/no-throw: warn

Частичное применение функций

Частичное применение функций, вероятно, является одним из лучших когда-либо изобретённых механизмов совместного использования кода. Вы можете внедрить зависимости в свой код, не прибегая ко всем типичным шаблонам ООП.

В следующем примере оборачивается библиотека Axios. Она печально известна тем, что выдаёт исключения (вместо возврата ошибочного ответа). Работать с такими библиотеками сложно, особенно при использовании async/await.

В следующем примере рассмотрим использование карринга и частичное использование функций, чтобы сделать небезопасную функцию безопасной.

// Оборачиваем axios для безопасного вызова api без отбрасывания исключений
const safeApiCall = ({ url, method }) => data =>
  axios({ url, method, data })
    .then( result => ([null, result]) ) 
    .catch( error => ([error, null]) );
    
// Частично применяем общую функцию выше для работы с пользовательским API
const createUser = safeApiCall({
    url: '/api/users',
    method: 'post'
  });
  
// Безопасно вызываем API без беспокойства об исключениях
const [error, user] = await createUser({
  email: 'ilya@suzdalnitski.com',
  password: 'Password'
});

Обратите внимание, что функция safeApiCall записывается как func = (params) => (data) => {...}. Это распространённая техника в функциональном программировании, называемая каррингом. Она всегда идёт рука об руку с частичным применением функций. Это означает, что функция func при вызове с params возвращает другую функцию, которая фактически выполняет работу. Другими словами, функция частично применяется с params.

Это также может быть записано как:

const func = (params) => (
   (data) => {...}
);

Обратите внимание, что зависимости (params) передаются как первый параметр, а фактические данные передаются как второй параметр.

Чтобы упростить задачу, вы можете использовать npm-пакет saferr, который также работает с промисами и async/await:

import saferr from "saferr";
import axios from "axios";

const safeGet = saferr(axios.get);

const testAsync = async url => {
  const [err, result] = await safeGet(url);

  if (err) {
    console.error(err.message);
    return;
  }

  console.log(result.data.results[0].email);
};


// Выводит: zdenka.dieckmann@example.com
testAsync("https://randomuser.me/api/?results=1");

// Выводит: Network Error
testAsync("https://shmoogle.com");

Несколько маленьких хитростей

Вот несколько маленьких, но полезных трюков. Они необязательно делают код более надёжным, но могут сделать вашу жизнь немного проще.

Немного безопасности типов

JavaScript не является статически типизированным языком. Но вы можете прибегнуть к небольшой хитрости, чтобы сделать код более надёжным, пометив аргументы функции как необходимые. Следующий код будет выдавать ошибку всякий раз, когда требуемое значение не было передано. Обратите внимание, что оно не будет работать для нулевых значений, но по-прежнему отлично защищает от undefined-значений.

const req = name => {
  throw new Error(`The value ${name} is required.`);
};

const doStuff = ( stuff = req('stuff') ) => {
  ...
}

Вычисление условий короткого замыкания

Условия короткого замыкания широко известны и полезны для доступа к значениям во вложенных объектах.

const getUserCity = user =>
  user && user.address && user.address.city;
  
const user = {
  address: {
    city: "San Francisco"
  }
};

// Возвращает "San Francisco"
getUserCity(user);

// Оба возвращают undefined 
getUserCity({});
getUserCity();

Вычисление короткого замыкания полезно для предоставления альтернативного значения, если значение является ложным:

const userCity = getUserCity(user) || "Detroit";

Bang bang!!

Отрицание значения дважды — отличный способ сделать любое значение логическим. Имейте в виду, что любое ложное значение будет преобразовано в false. Это не всегда то, чего вы хотите. Никогда не используйте это для чисел, так как 0 также будет преобразован в false.

const shouldShowTooltip = text => !!text;

// Возвращает true
shouldShowTooltip('JavaScript rocks');

// Все возвращают false
shouldShowTooltip('');
shouldShowTooltip(null);
shouldShowTooltip();

Отладка с логированием на месте

Вы можете использовать короткое замыкание и тот факт, что результат console.log не подходит для отладки функционального кода, даже включая компоненты React:

const add = (a, b) =>
  console.log('add', a, b)
  || (a + b);

const User = ({email, name}) => (
  <div>
    <Email value={console.log('email', email) || email} />
    <Name value={console.log('name', name) || name} />
  <div/>
);

Что дальше?

Действительно ли вам нужна надёжность кода? Это решать вам. Вы работаете на фабрике фич, где количество важнее качества? Надеюсь, что нет. Но если это так, то вы можете подумать о поиске лучшего места работы.

Не пытайтесь применить сразу всё из этой статьи. Поместите её в закладки и продолжайте возвращаться к ней время от времени. Каждый раз убирайте одну вещь, на которой вы в данный момент сосредоточились. И включите соответствующие правила ESLint, чтобы себе помочь.