Распространённые ошибки в JavaScript и TypeScript, которые тайно ломают код
Ловушки JavaScript и TypeScript, которые кажутся правильными, но всё равно приводят к ошибкам: сравнения NaN, принудительная конвертация типов, проблемы с таймингом в async/await и проверки равенства, вводящие в заблуждение.
Источники: 20 JavaScript/TypeScript Mistakes That Look Right… But Break Your App
Ловушки сравнения и равенства
1. NaN никогда не равен ничему, даже самому себе.
[1, NaN, 3].indexOf(NaN); // -1, even though NaN is right there
Согласно спецификации, NaN === NaN возвращает false, поэтому любой механизм, основанный на строгом сравнении равенства — включая indexOf — не сможет его найти, даже если он находится прямо в массиве. Вместо этого используйте Number.isNaN(value) или .includes(), которые специально разработаны для корректной обработки этого крайнего случая.
2. typeof null возвращает "object".
if (typeof value === "object") { value.someMethod(); } // value could be null
Это старая особенность языка, которая мешает тем, кто считает typeof надежным способом определения того, является ли объектом данный элемент. Значение null проходит эту проверку, но вызывает ошибку сразу при попытке вызвать на нем метод.
3. Сравнение объектов с помощью JSON.stringify() чувствительно к порядку.
JSON.stringify({ a: 1, b: 2 }) === JSON.stringify({ b: 2, a: 1 }); // false
Два объекта, представляющие одни и те же данные, могут сериализоваться в строки с ключами в разном порядке — это естественно происходит после обработки в базе данных или в другой части кодовой базы. Вместо сравнения строк следует использовать специальные инструменты для проверки глубокого равенства.
4. Метод .sort() без функции сравнения рассматривает все элементы как текст.
[10, 2, 1].sort(); // [1, 10, 2]
Сначала значения преобразуются в строки, а затем сравниваются символ за символом, что нарушает правильный порядок чисел. Всегда используйте явную функцию сравнения для чисел, например (a, b) => a - b.
Принудительное преобразование типов
5. Оператор + переходит на объединение строк, как только хотя бы один из операндов является строкой.
function total(price, quantity) {
return price + quantity; // "5" + 3 = "53" if price came from a form input
}
Значения, поступающие из параметров запроса, полей формы или парсированного JSON, часто остаются строками, даже если выглядят как числа. Оператор + не будет преобразовывать строку в число — вместо этого он преобразует число в строку.
6. Значения по умолчанию при деструктуризации применяются только тогда, когда свойство равно undefined, никогда не когда оно равно null.
function greet({ name = "friend" } = {}) { return `Hi, ${name}`; }
greet({ name: null }); // "Hi, null" — the default never kicks in
Явное передача значения null считается предоставлением значения, а не его опущением, поэтому фоновое значение полностью игнорируется. Если вызывающие стороны могут законно отправлять null, необходимо проверять его отдельно.
Асинхронное время
7. Выполнение операции .filter(Boolean) над массивом обещаний ничего не даёт.
const results = items.map((item) => checkAsync(item)); // array of Promise objects
const valid = results.filter(Boolean); // every Promise is truthy, filters nothing
Экземпляр Promise всегда считается истинным, независимо от того, к чему в конечном итоге он приведёт — к выполнению или отклонению. Сначала необходимо привести их к выполнению с помощью await Promise.all(results), а уже затем отфильтровать полученные значения.
8. Цепочка опциональных операций прерывает оценку всего выражения, а не только его опциональной части.
user?.getPermissions().includes("admin"); // if user is null, this is undefined, not an error, but also never calls .includes at all
Может показаться, что пропускается только сегмент ?., но все последующие операции тоже прекращаются. Обычно это и является желаемым поведением, однако возникает скрытая ошибка, когда последняя вызовка — например, для логгирования или аналитики — бесшумно так и не выполняется.
9. Promise.all удаляет все результаты сразу же, как только хотя бы один обещание отклоняется.
const [a, b, c] = await Promise.all([taskA(), taskB(), taskC()]);
// if taskC rejects, you get nothing back from taskA and taskB either, even if they succeeded
Это преднамеренное поведение «всё или ничего». Если в вашей ситуации допустима частичная удача, Promise.allSettled возвращает результат каждого обещания — независимо от того, успешно оно выполнено или нет — вместо того чтобы игнорировать успешные результаты из-за провала одной задачи.
Изменение объектов и массивов
10. const блокирует саму переменную, а не объект, на который она указывает.
const config = { retries: 3 };
config.retries = 10; // completely legal, const never protected this
Использование ключевого слова const лишь предотвращает переопределение переменной новым значением. Объект, на который она ссылается, остаётся таким же изменяемым, как и в случае использования let. Если требуется настоящая неизменяемость, следует использовать Object.freeze() — причём имейте в виду, что это функция замораживает данные только на одном уровне.
11. Метод .sort() изменяет исходный массив, в отличие от .map() или .filter().
const original = [3, 1, 2];
const sorted = original.sort();
console.log(original); // [1, 2, 3] — also changed, probably not what was intended
Легко предположить, что все методы массивов возвращают новый массив, поскольку большинство распространённых методов действительно делают именно это. .sort() и .reverse() являются исключениями, и изменение массива, на который другая часть кода всё ещё имеет ссылку, приводит к классической ошибке «это значение изменилось само по себе».
12. for...in также проходит по наследованным свойствам, а не только по собственным ключам объекта.
Array.prototype.customMethod = function () {};
const arr = [1, 2, 3];
for (const key in arr) { console.log(key); } // includes "customMethod" too
На практике это редко становится проблемой, поскольку расширение встроенных прототипов встречается нечасто, но любое перечисляемое свойство, унаследованное из цепочки прототипов, появится в цикле for...in. Использование Object.keys() или for...of полностью избавляет от таких неожиданностей.
Функции и область видимости
13. Функции-стрелки никогда не привязывают собственное значение this.
const counter = {
count: 0,
increment: () => { this.count++; }, // `this` here is NOT the counter object
};
Функция-стрелка берет значение this из лексической области видимости, в которой она была определена, а не из объекта, который её вызывает. Это делает их идеальными для обратных вызовов, которым нужно сохранять доступ к внешнему значению this, но плохим выбором для методов объектов или классов, которые должны обращаться к своему собственному экземпляру.
14. При отсутствии инструкции break оператор switch продолжает выполнение без ошибок.
switch (status) {
case "pending":
console.log("Order is pending");
case "shipped": // no break above, this runs too
console.log("Order has shipped");
}
Если пропустить хотя бы одну инструкцию break, все последующие варианты будут выполнены, независимо от того, соответствует ли их условие действительности. Умышленное продолжение выполнения иногда встречается, но это настолько редкое явление, что отсутствие break почти всегда указывает на баг, а не на осознанный выбор проектировщика.
Даты и числа
15. Выражение 0.1 + 0.2 не равно 0.3.
0.1 + 0.2 === 0.3; // false
0.1 + 0.2; // 0.30000000000000004
Это связано со стандартным представлением чисел с плавающей точкой и касается почти всех языков, а не только JavaScript. Избегайте использования оператора строгого равенства для вычисленных десятичных значений; вместо этого проверяйте, попадает ли разница между двумя числами в допустимые пределы толерантности.
16. Чистые строковые представления дат интерпретируются как UTC, в то время как даты, разделенные слэшами, обрабатываются по местному времени.
new Date("2024-01-01"); // midnight UTC
new Date("01/01/2024"); // midnight local time
Две строки, кажущиеся описанием одного и того же дня, могут соответствовать разным календарным датам в зависимости от часового пояса того, кто запускает код. Выберите один формат дат и последовательно им пользуйтесь, а также воспользуйтесь хорошо отлаженной библиотекой для работы с датами вместо ручной обработки строк.
Специфические проблемы TypeScript
17. Утверждения типов не выполняют никакой проверки во время выполнения.
const user = JSON.parse(rawData) as User; // TypeScript trusts you, nothing is actually checked
Запись as User говорит компилятору полностью доверять вам, и он делает именно это, не проверяя, действительно ли обработанное значение соответствует заявленной структуре. Некорректные или неожиданные данные продолжают поступать в программу с меткой «проверено», пока в конечном итоге не вызовут сбой где-то далее в работе программы.
18. Оператор утверждения, что значение не равно null, лишь подавляет предупреждения, он не меняет реальность.
function getName(user?: User) {
return user!.name; // TypeScript stops warning you; the runtime null check is still gone
}
Добавление ! сообщает TypeScript, что вы уверены, что значение не является ни null, ни undefined. Если это предположение ошибочно, компилятор просто перестаёт отмечать риск, и ошибка проявляется во время выполнения, именно там, где раньше её мог выявить система типов.
19. Проверка на наличие лишних свойств применяется только к объектным литералам, переданным напрямую, а не к переменным с такой же структурой.
type Config = { retries: number };
function setup(config: Config) {}
setup({ retries: 3, timeout: 5000 }); // error: 'timeout' does not exist on type Config
const options = { retries: 3, timeout: 5000 };
setup(options); // no error — same excess property, but it slips through
Если передавать объект-литерал непосредственно в вызов функции, TypeScript тщательно проверяет каждое свойство, отклоняя любые лишние элементы. Если сначала присвоить этот же объект переменной, а затем передать саму переменную, проверка полностью исчезает. Это настоящая особенность работы проверки типов: ошибка в написании или случайное лишнее поле могут незаметно пройти проверку в зависимости от того, как значение попало в функцию.
20. Числовые enum принимают произвольные числа, а не только заявленные в них элементы.
enum Status { Pending, Shipped, Delivered }
function process(status: Status) {}
process(999); // no compile error, even though 999 isn't a declared Status value
По сути, числовые enum в TypeScript — это просто обычные числа, поэтому система типов считает любое числовое значение допустимым, независимо от того, соответствует ли оно объявленному элементу. Строковые enum вводят реальные ограничения, поэтому когда вам нужно, чтобы компилятор отклонял некорректные данные, лучше использовать строковые enum или объединение, составленное из строковых литералов.
Ни одна из этих двадцати проблем не является сложной или необычной. Каждая из них достаточно распространена, чтобы встречаться в реальном коде, прошедшем проверку и выпущенном в продакшн, именно потому, что он выглядит как обычный JavaScript или TypeScript до тех пор, пока конкретные данные, момент времени или крайний случай не выявят её проблему. Ознакомление с этим списком не заменит необходимость тщательной проверки кода. Оно даёт вам преимущество: вы сможете заранее проверять те проблемы, на которые уже обращали внимание, вместо того чтобы столкнуться с ними позже в продакшене.