Поширені проблеми 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. Оператор switch продовжує виконання без змін, якщо пропустити break.
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 Status { Pending, Shipped, Delivered }
function process(status: Status) {}
process(999); // no compile error, even though 999 isn't a declared Status value
Під капотом числові енумерації в TypeScript — це просто звичайні числа, тому система типів вважатиме будь-яке числове значення дійсним, незалежно від того, чи воно відповідає оголошеному елементу. Строкові енумерації запроваджують справжні обмеження, тож коли вам потрібно, щоб компілятор фактично відхилив некоректний вхідні дані, краще використовувати строкові енумерації або союз, створений зі строкових літералів.
Жодна з цих двадцяти проблем не є складною чи екзотичною. Кожна з них настільки поширена, що зустрічається у реальному коді, який проходив перевірку та випускався, саме тому що він схожий на звичайний JavaScript чи TypeScript, поки певні вхідні дані, момент часу або крайні випадки не розкривають її. Ознайомлення з цим списком не замінить необхідності ретельно тестувати ваш код. Воно дає вам перевагу: ви будете перевіряти проблеми, про які вже знаєте, а не знаходити їх випадково під час роботи продукту.