Изоморфная разработка в JavaScript предполагает переход к универсальной системе управлению исключениями, которая способна единообразно перехватывать, логировать и обрабатывать ошибки вне зависимости от
выполняемых функций (если эти функции следуют некоторому общему протоколу) и предполагает стандартизацию формата ошибок.
Ее цель — снизить дублирование кода, улучшить сопровождение и обеспечить предсказуемое поведение при возникновении сбоев. Дело в том, что обработка ошибок с помощью конструкции try...catch может показаться громоздкой, особенно когда приходится писать вложенные блоки try..catch. С блоками try...catch
связан ряд проблем. Например, соответствующие блоки захватывают создаваемые в этих блоках значения, соответственно приходится выносить значения во внешние переменные. А глубокая вложенность функций, которые также могут генерировать исключения, усложняют и код, и понимание того, что он делает.
Рассмотрим простейший пример:
// определение обертки для обработки ошибок
function tryCatch (fn){
try{
return [fn(), null];
}
catch(err){
return [null, err];
}
}
// использование
const sqrt = (x) => {
if(x < 0) throw new Error(`Number ${x} is invalid`);
return Math.sqrt(x);
}
// пример получения ошибки
const [res1, err1] = tryCatch(()=>sqrt(-4));
if(err1) console.error(err1);
else console.log("sqrt(-4):", res1);
// пример получения результата
const [res2, err2] = tryCatch(()=>sqrt(4));
if(err2) console.error(err2);
else console.log("sqrt(4):", res2);
Этот код демонстрирует обёртку над функцией для безопасного выполнения с возвратом массива [результат, ошибка] вместо ручного использования try/catch.
Вначале определяется функция tryCatch:
function tryCatch (fn){
try{
return [fn(), null];
}
catch(err){
return [null, err];
}
}
tryCatch через параметр fn принимает другую функцию и вызывает ее внутри блока try/catch. В качестве результата возвращается массив с двумя элементами.
Если функция fn выполняется без ошибок, то возвращается массив [результат, null].
Если происходит ошибка — возвращается [null, ошибка].
Это делает вызовы функций более чистыми, особенно при работе с возможными исключениями.
Далее для примера определена обычная функция sqrt, которая вычисляет квадратный корень переданного в нее числа с помощью встроенной функции Math.sqrt() и которая генерирует ошибку при отрицательных значениях (так как нельзя вычислить квадратный корень для отрицательных чисел).
const sqrt = (x) => {
if (x < 0) throw new Error(`Number ${x} is invalid`);
return Math.sqrt(x);
};
Далее идут примеры вызова. В первом случае вызывается sqrt(-4), что вызывает исключение:
const [res1, err1] = tryCatch(()=>sqrt(-4));
Здесь tryCatch ловит исключение и возвращает массив [null, Error("Number -4 is invalid")]
Во втором случае вызов sqrt(4) возвращает 2, соответственно ошибки нет
const [res2, err2] = tryCatch(()=>sqrt(4));;
Фактически возвращается массив [2, null]
В итоге мы получим следующий вывод в консоли браузера:
Error: Number -4 is invalid sqrt(4): 2
Возьмем другой пример - создание объекта класса:
class Person{
constructor(name, age){
if(age < 0) throw `Недопустимый возраст ${age}. Минимальное значение: 1`;
if(name.length < 2) throw `Недопустимое имя ${name}: минимальная длина имени - 2 символа`;
this.name = name;
this.age = age;
}
print(){ console.log(`Name: ${this.name} Age: ${this.age}`);}
}
В данном случае класс Person генерирует исключение, если в конструктор передан недопустимый возраст - меньше 0 или если длина переданного имени меньше 2 символов. Используем вышеопределенную функцию tryCatch для обработки исключений:
function tryCatch (fn){
try{
return [fn(), null];
}
catch(err){
return [null, err];
}
}
class Person{
constructor(name, age){
if(age < 0) throw `Недопустимый возраст ${age}. Минимальное значение: 1`;
if(name.length < 2) throw `Недопустимое имя ${name}: минимальная длина имени - 2 символа`;
this.name = name;
this.age = age;
}
print(){ console.log(`Name: ${this.name} Age: ${this.age}`);}
}
// для примера пытаемся создать пару объектов
const [tom, err1] = tryCatch(() => new Person("Tom", -123));
if(err1) console.error(err1);
else tom.print();
const [bob, err2] = tryCatch(() => new Person("Bob", 46));
if(err2) console.error(err2);
else bob.print();
Здесь для примера пытаемся создать два объекта Person, передавая в вызов tryCatch соответствующий вызов конструктора. И опять же мы получаем массив, где первый элемент - результат, а второй - информация об ошибке. Поскольку в первом случае в конструктор Person передаются заведомо некорректные данные, то мы получим ошибку, а во втором случае - объект Person:
Недопустимый возраст -123. Минимальное значение: 1 Name: Bob Age: 46
Стоит отметить, что выше представлена только одна из возможных реализаций. Некоторые особенности могут отличаться. Например, функция-обертка может дополнительно обрабатывать ошибку. Так, стандартный тип для ошибок в JavaScript - это тип Error. Однако в коде разработчики не всегда генерируют ошибки этого типа. Например, в коде выше в конструкторе класса Person ошибка по сути представляет простую строку:
if(age < 0)
throw `Недопустимый возраст ${age}. Минимальное значение: 1`; // ошибка представляет строку
И функция-обертка может проверять тип и при необходимости обертывать ошибку в тип Error:
function tryCatch (fn){
try{
return [fn(), null];
}
catch(err){
// проверяем, представляет ли ошибка err объект Error
const error = err instanceof Error ? err : new Error(String(err))
return [null, error];
}
}
Также сама функция-обертка может отличаться. Так, мы могли бы определить следующую функцию:
const tryWrap = (fn) => (...args) => {
try{
return [fn(...args), null];
}
catch(err){
return [null, err];
}
}
tryWrap определена как стрелочная функция, которая через параметр fn принимает другую функцию и возвращает новую функцию. Возвращаемая функция вызывает функцию fn с переданными аргументами (...args),
но делает это внутри блока try/catch. Обратите внимание, что через параметр args можно передать произвольное количество аргументов. В остальном логика та же самая, что и у выше определнной функции tryCatch:
если функция fn выполняется без ошибок, то возвращается массив [результат, null]. Если происходит ошибка, то возвращается массив [null, ошибка].
Пример использования:
// на примере функции sqrt
const [res1, err1] = tryWrap(sqrt)(-4);
const [res2, err2] = tryWrap(sqrt)(4);
// на примере конструктора класса Person
const [tom, tomErr] = tryWrap((name, age)=>new Person(name, age))("Tom", -123);
const [bob, bobErr] = tryWrap((name, age)=>new Person(name, age))("Bob", 46);
Можно в принципе не определять функцию-обертку, если используемые функции изначально поддерживают требуемые контракт - возвращают информацию о результате функции и ошибке в требуемом формате. Например:
// функция возвращает данные в формате "[результат, ошибка]""
const sqrt = (x) => {
if(x < 0) return [null, new Error(`Number ${x} is invalid`)];
return [Math.sqrt(x), null];
}
const [res1, err1] = sqrt(-4);
if(err1) console.error(err1);
else console.log("sqrt(-4):", res1);
const [res2, err2] = sqrt(4);
if(err2) console.error(err2);
else console.log("sqrt(4):", res2);
Таким образом мы получили унифицированный способ для обработки ошибок, и нам не надо писать try/catch каждый раз при вызове отдельной функции, которая может сгенерировать ошибку.
Особенно данный подход упрощает написание кода в функциональном стиле. Для асинхронных операций этот подход можно адаптировать в виде async/await-совместимого варианта.
В итоге счет изоморфной обработки ошибок упрощается код, улучшается его отладка и мониторинг благодаря централизованной обработке ошибок, упрощается повторное использование код и повышается надёжности приложения за счёт предсказуемой реакции на сбои.