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

Список ниже — не претензии к языку. Это карта мест, куда джун наступает первым и потом полдня ищет, почему в счёте вместо 139,93 стоит 139,92999999999998. У каждой границы есть штатный обход, и половину из них язык уже закрыл сам — просто про это редко пишут в туториалах.

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

Одно число на все случаи жизни

В JavaScript нет отдельного типа для целых и отдельного для дробных. Есть один Number — 64-битное число с плавающей точкой (тот самый формат double из стандарта IEEE 754). Одно хранилище на всё: и на количество товаров, и на цену, и на координату.

Отсюда самый известный сюрприз языка:

const total = 0.1 + 0.2;

console.log(total);         // 0.30000000000000004
console.log(total === 0.3); // false

Движок не сломан. Просто 0.1 в двоичной системе — бесконечная дробь, ровно как 1/3 в десятичной. Записать её в 64 бита целиком нельзя, приходится округлять, и на сложении округления складываются тоже. Убедиться легко:

console.log((0.1).toFixed(20)); // "0.10000000000000000555"

Это не особенность JavaScript — так же считают Python, Java и C. Разница в том, что там рядом лежат целочисленные типы и десятичные библиотеки, а здесь долгое время не было ничего.

Другие классические странности языка — в отдельном разборе про неожиданные моменты JavaScript.

Целые числа ломаются тоже

Про дроби знают почти все, а вот про потолок у целых — почти никто. Точность double хватает на целые примерно до 2 в 53-й степени, дальше числа начинают слипаться:

console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991

console.log(9007199254740993);        // 9007199254740992
console.log(Number.isSafeInteger(9007199254740993)); // false

Выглядит как экзотика, пока не приходит ответ от сервера. Идентификаторы записей в больших базах спокойно перешагивают этот порог, и разбор JSON молча меняет значение:

const order = JSON.parse('{"id": 9007199254740993}');

console.log(order.id); // 9007199254740992 — это уже другой заказ

Лечится на стороне API: большие идентификаторы отдают строкой. Если менять API нельзя — есть BigInt, отдельный тип для целых любой длины. Литерал помечается суффиксом n:

const orderId = 9007199254740993n;

console.log(orderId + 1n); // 9007199254740994n

Только держи в голове две ловушки. Смешивать BigInt с обычными числами нельзя — 1n + 1 бросит TypeError. И JSON.stringify на таком значении тоже падает, сериализовать придётся вручную.

Что делать с дробями на практике

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

const priceCents = 1999; // 19.99 EUR
const quantity = 7;

const totalCents = priceCents * quantity;    // 13993 — целое, точность не теряется
console.log((totalCents / 100).toFixed(2));  // "139.93"

// а если считать «как есть», в счёт уйдёт вот это:
console.log(19.99 * 7);                      // 139.92999999999998

Если дроби всё-таки неизбежны, сравнивать их через === бесполезно — сравнивают с допуском:

const isEqual = Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON;

console.log(isEqual); // true

А для сумм по массиву в языке появился отдельный метод — Math.sumPrecise. Он складывает так, будто внутри бесконечная точность, и округляет только результат:

const parts = [0.1, 0.2, 0.3];

console.log(parts.reduce((acc, n) => acc + n, 0)); // 0.6000000000000001
console.log(Math.sumPrecise(parts));               // 0.6
Поддержка браузерами
chrome
Chrome
147
firefox
Firefox
137
edge
Edge
147
safari
Safari
26.2
opera
Opera
131

Один поток на весь твой код

Второе ограничение звучит страшнее, чем работает: код на JavaScript выполняется в один поток. Команды идут строго по очереди, и пока одна не закончилась, следующая ждёт. Занял поток тяжёлым циклом — страница замерла целиком, ни клика, ни перерисовки:

const finishAt = Date.now() + 3000;

while (Date.now() < finishAt) {
  // три секунды поток занят только этим
}

console.log("отмерли");

В списках ограничений эту границу часто разносят на два пункта — отдельно «язык делает одну задачу за раз» и отдельно «нет многопоточности». На деле это одно и то же, просто описанное с двух сторон.

Настоящая параллельность в языке всё-таки есть — это Web Workers: отдельный скрипт в отдельном потоке, который общается с основным сообщениями, а тяжёлые данные может делить через SharedArrayBuffer. Расплата — никакого доступа к DOM изнутри воркера.

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

Асинхронность: от лесенки колбэков к плоскому коду

Раз поток один, долгие операции приходится делать асинхронно. Первым способом были колбэки — функции, которые вызовут, когда результат будет готов. Пока операция одна, всё хорошо. Когда их три подряд, код заворачивается:

loadUser(userId, (user) => {
  loadOrders(user.id, (orders) => {
    loadInvoice(orders[0].id, (invoice) => {
      renderInvoice(invoice);
    });
  });
});

Это и называют адом колбэков (callback hell). Читать неудобно, а обрабатывать ошибки — ещё хуже: свой try нужен на каждом уровне, потому что исключение из вложенного колбэка наружу не всплывает.

Промисы и async/await убрали проблему полностью. Та же цепочка выглядит как обычный последовательный код, и один try ловит всё:

try {
  const user = await loadUser(userId);
  const orders = await loadOrders(user.id);
  const invoice = await loadInvoice(orders[0].id);

  renderInvoice(invoice);
} catch (error) {
  showError(error);
}

Так что этот пункт из списка ограничений скорее исторический: язык его закрыл.

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

let resolveReady;
let rejectReady;

const ready = new Promise((resolve, reject) => {
  resolveReady = resolve;
  rejectReady = reject;
});

Теперь то же самое даёт одна строка — Promise.withResolvers возвращает промис и обе его функции сразу:

const { promise: ready, resolve, reject } = Promise.withResolvers();

socket.addEventListener("open", () => resolve());
socket.addEventListener("error", (event) => reject(event));

await ready; // ждём, пока соединение поднимется
Поддержка браузерами
chrome
Chrome
119
firefox
Firefox
121
edge
Edge
119
safari
Safari
17.4
opera
Opera
105

До железа язык не дотягивается

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

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

Максимально близко к байтам подпускает пара ArrayBuffer и DataView: буфер — это кусок сырой памяти, а представление умеет читать и писать в него числа нужной разрядности.

const buffer = new ArrayBuffer(8);
const view = new DataView(buffer);

view.setInt32(0, 256, true);          // true — порядок байтов little-endian

console.log(view.getInt32(0, true));  // 256   — читаем так же, как писали
console.log(view.getInt32(0, false)); // 65536 — те же байты, порядок обратный

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

Когда нужна настоящая производительность и работа с памятью, задачу выносят в WebAssembly: код на C, C++ или Rust компилируется в байт-код, который движок исполняет рядом с JavaScript и через тот же ArrayBuffer обменивается данными.

А детерминированное освобождение ресурсов язык всё-таки завёл — это объявление using. Переменная, объявленная через него, вызывает свой метод Symbol.dispose на выходе из блока: при обычном завершении, при return и при исключении.

function openReader(stream) {
  const reader = stream.getReader();

  return {
    reader,
    [Symbol.dispose]() {
      reader.releaseLock();
    },
  };
}

{
  using handle = openReader(stream);
  const { value } = await handle.reader.read();

  processChunk(value);
} // releaseLock вызовется здесь сам — даже если processChunk бросит исключение
Поддержка браузерами
chrome
Chrome
134
firefox
Firefox
141
edge
Edge
134
safari
Safari
 
opera
Opera
119

Типы и объекты: не хуже, а иначе

Последние два пункта классического списка — свободная типизация и прототипы вместо классов. Оба формулируются как недостаток, но на деле это просто другая модель, у которой есть цена.

Цена свободной типизации — ошибка не падает, а превращается в тихий баг:

function addPrice(a, b) {
  return a + b;
}

console.log(addPrice(10, 5));   // 15
console.log(addPrice("10", 5)); // "105" — исключения нет, есть неверная сумма

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

/**
 * @param {number} a
 * @param {number} b
 * @returns {number}
 */
function addPrice(a, b) {
  return a + b;
}

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

node app.ts

Ограничение у такого запуска одно: срезать можно только то, что не влияет на исполнение. Конструкции вроде enum и namespace генерируют код и потому не поддерживаются.

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

А вот жалоба на «неполноценное ООП» устарела. Приватные поля с решёткой языку уже завезли, и они настоящие, а не по договорённости через подчёркивание в имени:

class Cart {
  #items = [];

  add(item) {
    this.#items.push(item);
    return this;
  }

  get totalCents() {
    return this.#items.reduce((sum, item) => sum + item.priceCents, 0);
  }
}

const cart = new Cart();

cart.add({ priceCents: 1999 }).add({ priceCents: 500 });
console.log(cart.totalCents); // 2499

// строка cart.#items даже не запустится: SyntaxError на этапе разбора файла

Чего в языке действительно пока нет — это декораторов. Предложение висит в третьей стадии не первый год, браузеры его не реализовали, и всё, что выглядит как декоратор в коде на Angular или NestJS, обрабатывает компилятор TypeScript, а не движок.

Что чинится, а что останется навсегда

Сведём список в таблицу: половина пунктов давно закрыта самим языком, а вторая половина — это плата за то, что код исполняется в браузере на чужой машине.

Ограничение Чем закрывается Останется?
Точность дробей Считать в минимальных единицах, сравнивать через Number.EPSILON, суммировать через Math.sumPrecise Да, формат double не поменяют
Потолок у целых чисел BigInt, идентификаторы строкой Да, но обход штатный
Один поток у кода Долгие задачи — браузеру, тяжёлые вычисления — в Web Workers Да, и это осознанный выбор
Лесенка вложенных колбэков Промисы, async/await, Promise.withResolvers Нет, уже решено
Нет доступа к памяти и диску ArrayBuffer с DataView, WebAssembly, using для освобождения ресурсов Да — это песочница, а не пробел
Свободная типизация JSDoc, TypeScript, нативный запуск типизированных файлов в Node.js Нет, инструменты закрывают
Прототипы вместо классов class, приватные поля с решёткой, статические блоки Нет, синтаксис давно есть

Итог

Три пункта из семи язык закрыл сам, пока эти жалобы расходились по конференциям и подборкам: колбэки заменились на async/await, инкапсуляция — на приватные поля, а типы приехали слоем сверху, который теперь запускается без сборки.

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

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