Если у JavaScript и есть фирменная фишка, так это коллекция выражений, на которых даже опытный разработчик морщит лоб. [] == ![]true. typeof null«object». Math.max() без аргументов — -Infinity. Со стороны выглядит как ошибки реализации, но почти всё это — задокументированное поведение, которое прописано в спецификации языка с конца девяностых.

Чтобы такие выражения не пугали, а наоборот укрепляли понимание языка, ниже разобраны шесть тематических групп типичных «странностей» — с короткими сниппетами и объяснением, что именно делает движок на каждом шаге.

Почему == доверчивее, чем кажется

Оператор нестрогого равенства == старается сравнить любые два операнда — даже разных типов. Если типы не совпали, движок включает приведение типов (type coercion): пытается привести значения к общему виду, обычно к числу, и только потом сравнивает. Отсюда и берутся почти все парадоксы из подборок «wat?!».

2 == [2]            // true
[] == ![]           // true
null == undefined   // true
true == "1"         // true
false == "0"        // true

Разбираем по шагам. В первом случае массив [2] при сравнении с числом проходит через внутренний алгоритм ToPrimitive: вызывается valueOf(), потом toString(), и получается строка "2". Дальше строка приводится к числу — 2. Сравнение 2 == 2 очевидно истинно.

Во втором случае справа стоит ![]. Оператор ! сначала приводит операнд к булевому значению, и для любого объекта — даже пустого массива — результат true, потому что объекты всегда truthy. Инверсия даёт false. Слева остаётся пустой массив, который при приведении к числу превращается в 0. Сравниваем 0 == falsefalse приводится к 0, и снова сходится.

Третий случай — единственное исключение в этом разделе, которое прописано в спеке отдельной строкой: null и undefined равны друг другу и больше ничему. Это сделано осознанно, чтобы одной проверкой x == null ловить оба варианта «значения нет».

Последние две строчки разбираются так же, как и первая: булевые значения сначала приводятся к числу (true → 1, false → 0), строки тоже — и сравниваются уже числа. То есть true == "1" по факту превращается в 1 == 1.

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

Тема truthy/falsy ценностей и того, как !! используют для явного приведения к булевому, разобрана в отдельной статье про двойной восклицательный знак.

typeof и его исторические косяки

Оператор typeof возвращает строку с названием типа — вроде бы простой и предсказуемый. Но два значения он возвращает совсем не такие, как ожидаешь:

typeof NaN    // "number"
typeof null   // "object"

С NaN всё чуть-чуть логично: аббревиатура расшифровывается как Not a Number, но по факту это специальное значение внутри числового типа IEEE 754, тот же тип, что у 3.14 или Infinity. Поэтому typeof честно отвечает "number".

С null история другая — это известный баг первой реализации JavaScript 1995 года, который намеренно не стали чинить ради обратной совместимости. В исходниках движка тип хранится в нижних битах указателя, и для объектов туда писались нули — null совпал по битовому представлению. К ES6 этот вопрос всплывал в комитете, было предложение исправить, но любой код, который проверяет typeof x === "object", тут же бы сломался — и решение оставили как есть.

Практический вывод: для проверок этих двух значений typeof не подходит. Для null — обычное равенство:

if (value === null) { /* ... */ }

Для NaNNumber.isNaN() из ES6, потому что глобальный isNaN() предварительно приводит аргумент к числу и даёт ложноположительные срабатывания на строках:

isNaN("hello")          // true  — строка превратилась в NaN
Number.isNaN("hello")   // false — в аргументе именно строка, не NaN
Number.isNaN(NaN)       // true

Ещё один полезный способ сравнить любые значения «как они есть» — метод Object.is(), который правильно различает NaN и пары +0 / -0:

NaN === NaN             // false
Object.is(NaN, NaN)     // true
Object.is(+0, -0)       // false
+0 === -0               // true

Плюс склеивает, минус считает

Оператор + в JavaScript — перегруженный: он одновременно и сложение чисел, и склеивание строк. Если хоть один операнд — строка, движок приводит второй тоже к строке и выполняет конкатенацию. Если строк нет — оба операнда приводятся к числу.

"1" + 1     // "11"
2 + "2"     // "22"
"5" - 3     // 2
"10" * "2"  // 20

А вот -, *, / и % работают только с числами — у них перегруженной строковой версии нет. Поэтому строки молча приводятся к числу, и выражение "5" - 3 возвращает обычное число 2.

На той же асимметрии построено самое известное JS-выражение из мемов:

"b" + "a" + + "a" + "a"   // "baNaNa"

Разбираем слева направо. После "b" + "a" получается "ba". Дальше встречается двойной плюс: первый — это всё ещё конкатенация, а второй — унарный плюс, попытка привести следующее значение к числу. Строка "a" к числу не приводится и возвращает NaN. Получается "ba" + NaN, что снова конкатенация: "baNaN". Последний + "a" добавляет в конец строку — "baNaNa". В консоли это выглядит как трюк, но никакой магии тут нет: только три правила приведения, применённых одно за другим.

Объекты сравниваются по ссылке, а цепочки — слева направо

Следующая пара странностей напоминает: даже визуально похожие выражения движок исполняет шагами, по правилам, и не «угадывает» намерение.

{} == {}        // false
[] === []       // false
7 > 6 > 5       // false

Два пустых объекта не равны друг другу, потому что оператор сравнения объектов проверяет идентичность ссылок, а не структурное совпадение. Каждый литерал {} создаёт новый объект в памяти — и адреса у них разные. Поэтому если нужно сравнить объекты «по содержимому», придётся писать это руками: пройтись по ключам через Object.keys() и сравнить попарно, либо использовать утилиту вроде lodash.isEqual. Про разные подходы к работе с объектами — статья про клонирование объектов, там же затрагивается тема ссылок и копирования.

Цепочка сравнений 7 > 6 > 5 в математике читается как «7 больше 6, и 6 больше 5». В JavaScript никакой особой связи между двумя сравнениями нет — они вычисляются последовательно, как обычные бинарные операторы. Сначала 7 > 6true. Потом true > 5: булево приводится к числу 1, и сравнение становится 1 > 5false. Чтобы написать «a больше b и b больше c», оба условия нужно явно соединить через &&:

7 > 6 && 6 > 5   // true

Сюда же стоит дописать одну из вечных тем для шуток про JavaScript:

0.1 + 0.2 === 0.3   // false
0.1 + 0.2           // 0.30000000000000004

Это, кстати, не баг и не вина JS — так ведут себя все языки, которые используют формат IEEE 754 для чисел с плавающей точкой (а это практически все языки). Часть десятичных дробей в двоичной системе записывается с бесконечным хвостом, поэтому при сложении накапливается крошечная погрешность. В практическом коде это лечится округлением через Number.prototype.toFixed() или сравнением с допуском:

Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON   // true

Странности встроенных API: Math и parseInt

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

Math.max()     // -Infinity
Math.min()     //  Infinity

На первый взгляд должно быть наоборот. Логика следующая: Math.max реализован как накопительная свёртка — берёт стартовое значение и сравнивает с каждым следующим аргументом, оставляя большее. Чтобы любое реальное число гарантированно «победило» на первом шаге, стартовым значением выбран -Infinity. Аргументов нет — и он же возвращается. Для Math.min симметрично: стартовое значение +Infinity.

Практический эффект: вызывая Math.max(...arr) на пустом массиве, в результате получится -Infinity, не ошибка. Если код потом использует это значение в арифметике, баг тихо расползётся дальше. Поэтому пустые массивы стоит проверять заранее.

Похожая ловушка у parseInt: функция определяет систему счисления по префиксу.

parseInt("10")        // 10
parseInt("0x10")      // 16  — распознан hex-префикс
parseInt("0b10")      // 0   — binary-префикс она не понимает
parseInt("08")        // 8

Последняя строчка раньше была настоящим pitfall: до ES5 ведущий ноль в строке трактовался как восьмеричный (октальный) префикс — и parseInt("08") в старых движках возвращал 0, потому что цифры 8 в восьмеричной системе не существует. На этом регулярно ловились скрипты, которые брали день месяца или минуты из строкового времени. ES5 (2009) убрал это поведение из parseInt по умолчанию, но привычка осталась: всегда передавайте второй аргумент — систему счисления:

parseInt("08", 10)    // 8 — явно десятичная, никаких сюрпризов

В современных проектах для парсинга десятичных чисел чаще берут Number() или унарный + — они не пытаются угадать систему счисления и работают предсказуемо.

Где var и delete ведут себя странно

Последний блок — не про приведение типов, а про область видимости. Тут две классические истории.

delete не удаляет аргументы функции. Оператор delete в JavaScript предназначен для удаления свойств объектов, а не переменных. Параметры функции формально не свойство, к ним delete просто не применяется — в обычном режиме возвращает false и ничего не делает:

(function (param) {
  delete param;
  return param;
})(42);
// 42 — параметр остался на месте

В строгом режиме ("use strict") та же попытка вообще ловится на этапе разбора и кидает SyntaxError. Подробный разбор строгого режима и того, что в нём ловится сразу — отдельный пост про use strict.

var и замыкания в цикле. Этот пример многих заставлял пересесть на let:

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// 3, 3, 3

Ожидание — 0, 1, 2. Реальность — три тройки. Причина в том, что var создаёт переменную с областью видимости всей функции, а не блока. Все три коллбэка ссылаются на одну и ту же ячейку памяти. К моменту, когда сработает первый setTimeout, цикл уже завершился, и в ячейке лежит 3. Замените var на let — и получится ожидаемое:

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// 0, 1, 2

Ключевое слово let объявляет переменную с блочной областью видимости — каждая итерация цикла получает свою независимую ячейку i, и коллбэк замыкает именно её. Подробнее про разницу между var, let и const — в отдельной статье про объявление переменных, а про hoisting и область видимости в целом — в материале про область видимости и поднятие.

Как с этим жить

Большая часть странностей выше — следствие свободного приведения типов и нескольких исторических решений начала девяностых. На практике их легко обойти, держа в голове несколько простых правил.

  • Использовать === и !== по умолчанию. Строгое равенство не приводит типы и убирает разом большинство сюрпризов вроде [] == ![]. Стайлгайды Airbnb, Google и StandardJS на этом сходятся; в ESLint правило eqeqeq включает проверку на любое использование ==.
  • Для NaNNumber.isNaN(), для одинаковости значений — Object.is(). Не глобальный isNaN() и не ===: ни тот, ни другой не различают нужные крайние случаи.
  • Не использовать var. Блочный let и иммутабельный const закрывают все нужные сценарии и не создают сюрпризов с замыканиями.
  • Всегда передавать второй аргумент parseInt. Даже если ES5 сделал поведение по умолчанию безопаснее — явный 10 читается лучше и сразу снимает вопросы у того, кто будет читать код через год.
  • Подумать про TypeScript на больших проектах. Статическая типизация ловит большую часть таких странностей ещё на этапе компиляции — складывать строку с числом в нём вообще не получится без явного приведения. Если ещё не пробовали — статья про то, почему стоит начать с TS.

Большинство «wat?!»-выражений в JavaScript — не магия и не баги, а закономерные следствия правил, которым язык следует ровно одинаково в любом движке. Зная эти правила, странности перестают быть сюрпризом и превращаются в обычные шаги вычисления.