Вес роутера 0.7000000000000001 кг на М.Видео. Штемпель 0.30000000000000004×2.5 см на Wildberries. Эти числа никто не вводил руками: где‑то значение посчитали и забыли округлить.

Если вы хоть раз выкатывали в прод что‑то вроде

0.1 + 0.2;

то знаете, какие отзывы бывают после этого.

Разберёмся, откуда берутся такие числа:

0.30000000000000004
0.7000000000000001

Ровно они и попали в карточки выше.

Постараюсь объяснить понятно, а не исчерпывающе. Цель в том, чтобы разобраться мог и дизайнер, и тестировщик, и бэкенд‑разработчик, а не только JS‑разработчик.

Для начала несколько примеров

Вот что JavaScript спокойно возвращает:

1.1 * 3
// 3.3000000000000003

0.57 * 100
// 56.99999999999999

4.35 * 100
// 434.99999999999994

0.1 + 0.7
// 0.7999999999999999

Но почему?

Возьмём обычный калькулятор:

10 / 3 = 3.333333333333...

1/3 нельзя точно записать конечным числом цифр в десятичной системе.

На это никто не обижается: мы к этому привыкли.

Внутри компьютера происходит то же самое. Просто проблема не с 1/3.

JavaScript хранит Number в двоичном формате

Обычный Number в JavaScript хранится в формате IEEE 754 binary64. На самом базовом уровне это значит, что число представлено битами:

0
1

Например:

0 → 0
1 → 1
2 → 10
3 → 11
4 → 100
5 → 101
6 → 110
7 → 111
8 → 1000

С небольшими целыми всё просто: они представляются точно. А что с дробями?

Как записать 0.25 в двоичной системе?

Для дробной части есть простой алгоритм.

Берём число и умножаем на 2. Целую часть записываем как очередной бит.

0.25 × 2 = 0.5 → 0
0.5 × 2 = 1.0 → 1

Дробная часть стала 0, останавливаемся:

0.25₁₀ = 0.01₂

Почему умножаем именно на 2? Потому что умножение на основание системы просто сдвигает запятую. В десятичной системе 3.14 × 10 = 31.4: цифра перепрыгнула через запятую. Умножение на два делает то же самое в двоичной системе. Каждый шаг выталкивает очередной бит, и мы его записываем.

Теперь попробуем 0.1

Здесь начинается самое интересное.

0.1 × 2 = 0.2 → 0
0.2 × 2 = 0.4 → 0
0.4 × 2 = 0.8 → 0
0.8 × 2 = 1.6 → 1
0.6 × 2 = 1.2 → 1
0.2 × 2 = 0.4 → 0
0.4 × 2 = 0.8 → 0
0.8 × 2 = 1.6 → 1
...

Мы снова и снова попадаем на одни и те же значения. Получается:

0.1₁₀ =
0.0001100110011001100110011...₂

И это 1 100 повторяется бесконечно.

С этого и начинается вся проблема.

Спросим у самого JavaScript

JavaScript может показать число в двоичном виде:

(0.1).toString(2);

Получим:

0.0001100110011001100110011001100110011001100110011001101

Важная оговорка: toString(2) не показывает буквально 64 бита, которые лежат в памяти. Это строковое представление числа в двоичной системе.

Но проблему оно показывает отлично: в двоичной системе 0.1 никогда не заканчивается.

Посмотрим на 0.1 с другой стороны

(0.1).toFixed(20);

Получаем:

"0.10000000000000000555"

То есть 0.1 это не совсем 0.1. Точнее, математическое 0.1 нельзя точно представить в используемом формате, поэтому JavaScript хранит ближайшее представимое значение.

С другими числами та же история:

(0.2).toFixed(20);
// "0.20000000000000001110"

(0.3).toFixed(20);
// "0.29999999999999998890"

А теперь самое интересное:

(0.1 + 0.2).toFixed(20);
// "0.30000000000000004441"

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

0.1 → 0.10000000000000000555
0.2 → 0.20000000000000001110

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

0.1 0.0001100110011001100110011001100110011001100110011001101
0.2 0.0011001100110011001100110011001100110011001100110011010
---------------------------------------------------------
sum 0.0100110011001100110011001100110011001100110011001100111

Чтобы точно записать эту промежуточную сумму, нужно больше значащих битов, чем позволяет binary64. Поэтому результат округляется до ближайшего представимого значения:

0.1 + 0.2 → 0.30000000000000004441

А настоящее 0.3 хранится наоборот, чуть меньше, чем нужно:

0.3 → 0.29999999999999998890
(0.1 + 0.2) === 0.3
// false

Вот и вся история. Оба слагаемых чуть великоваты, сумма округлилась ещё выше, и всё это сравнивается с числом, которое само чуть меньше нужного.

Ещё одна оговорка: toFixed(20) тоже не показывает содержимое памяти. Он просто форматирует уже сохранённое значение с 20 знаками после запятой.

Теперь главное. Формат, в котором JavaScript хранит числа, конечен: ровно 64 бита на число. А наша двоичная дробь бесконечна.

Если интересно, как эти 64 бита устроены внутри, подробности есть на MDN: Number encoding.

Откуда вообще взялся этот формат

Справедливый вопрос: кто решил, что числа надо хранить именно так, и почему все с этим согласились?

До IEEE 754 разные компьютеры и производители могли использовать разные форматы и разные правила работы с плавающей точкой. Вот как это описывает IEEE: форматы зависели от производителя, а индустрия раскололась на двоичный, восьмеричный и шестнадцатеричный лагеря. Одна и та же программа давала разные ответы на разных машинах, а арифметические библиотеки приходилось подгонять под каждую модель. Переносимых численных вычислений просто не существовало.

За это взялся математик из Беркли Уильям Кэхэн. Intel пришла к нему за внутренним корпоративным стандартом, а он убедил руководство разрабатывать арифметику с прицелом на будущий международный стандарт. Так появился IEEE 754, принятый в 1 985 году. В 1 989 году Кэхэн получил за это премию Тьюринга. В формулировке так прямо и сказано: за работу по созданию стандарта IEEE 754.

Несколько практических вещей, которые решают почти всё

Не сравнивайте числа с плавающей точкой через ===.

// это никогда не будет true
if (total === 0.3) {
  // сюда мы не попадём
}

// а так работает
const almostEqual = (a, b) =>
  Math.abs(a - b) <= Number.EPSILON * Math.max(1, Math.abs(a), Math.abs(b));

almostEqual(0.1 + 0.2, 0.3);
// true

Деньги, если бизнес‑логика позволяет, удобно хранить в минимальных единицах, например в копейках. Целые числа до Number.MAX_SAFE_INTEGER представляются и сравниваются безопасно.

const toKopecks = (rub) => Math.round(rub * 100);

toKopecks(0.1) + toKopecks(0.2);
// 30

Считайте в целых, а форматируйте для пользователя только при выводе.

Форматируйте через Intl.NumberFormat.

new Intl.NumberFormat("ru-RU", {
  style: "currency",
  currency: "RUB",
}).format(0.1 + 0.2);
// "0,30 ₽"

И не доверяйте toFixed на границах округления. Это самая коварная ловушка, потому что она выглядит как решение:

(1.005).toFixed(2);
// "1.00" ← ожидали 1.01

(2.675).toFixed(2);
// "2.67" ← ожидали 2.68

Причина та же, что и всегда: 1.005 внутри Number чуть меньше математического 1.005. toFixed() форматирует уже сохранённое значение и округляет по своим правилам. Представление числа он не исправляет.

Так что делать с такими карточками товаров?

Представим, что API уже отдал нам вот это:

Вес: 0.7000000000000001 кг

И мы просто выводим это на страницу.

Фронтенд может поставить перед выводом санитайзер, который ищет подозрительно длинные дробные числа и возвращает их в нормальный вид:

const fmt = new Intl.NumberFormat("en-US", {
  maximumSignificantDigits: 12,
  useGrouping: false,
});

function sanitizeText(text) {
  return text.replace(/(?<![\d.])\d+\.\d+(?![.\d])/g, (value) => {
    const [, fraction] = value.split(".");

    if (fraction.length < 12) {
      return value;
    }

    const number = Number(value);

    if (!Number.isFinite(number)) {
      return value;
    }

    return fmt.format(number);
  });
}

Проверяем:

sanitizeText("Вес: 0.7000000000000001 кг");
// "Вес: 0.7 кг"

sanitizeText("Штемпель с набором для, 0.30000000000000004×2.5 см синий");
// "Штемпель с набором для, 0.3×2.5 см синий"

sanitizeText("версия 1.2.3 вышла");
// "версия 1.2.3 вышла" (не тронуто)

Но это не решение. В лучшем случае экстренная заплатка.

Такой санитайзер нельзя использовать как универсальную обработку чисел. Фронтенд понятия не имеет, что значит вот это:

1.234567890123456789

Это может быть артефакт плавающей точки. А может быть реальное число, где важна каждая цифра.

Поэтому чинить нужно там, где число вычисляется.

Фронтенд может спрятать улики. Но лучше найти виновника.

Изначально я написал этот текст на английском для LinkedIn, для Хабра адаптировал примеры под наши магазины.

Комментарии (11)


  1. Rsa97
    20.10.2026 16:40

    откуда в JavaScript берутся такие числа

    Почему только в JavaScript? Такая проблема есть везде, где используется вещественная арифметика в представлении IEEE 754.
    А вообще, это [:|||:]. Обсуждалось уже много-много раз.
    https://0.30000000000000004.com/


    1. alexbuki Автор
      20.10.2026 16:40

      Интересно было самому разобраться поглубже и поделиться этим.
      Не спорю, что тема не нова, но все же...


  1. softi
    20.10.2026 16:40

    Мне вот интересно: как эта статья прошла модерацию? Она была бы актуальна лет 20-25 назад. Для чего она сейчас здесь?


    1. nerudo
      20.10.2026 16:40

      За 25 лет подросло новое поколение, делающее удивительные открытия!


      1. dbpatch
        20.10.2026 16:40

        За 25 лет подросло новое поколение, делающее удивительные открытия!

        Проблема 0.1 + 0.2 != 0.3 появилась еще в 1950-е, в мезозое цифровой эры, 75 лет назад, а не 25. И тогда-же были найдены moneysafe методы: и библиотеки хитрых округлений с IEEE арифметикой, и прочие BCD и DFP, и хранение сумм в виде копеек как целых чисел.
        Но каждый год все новые и новые новобранцы продолжают упорно использовать double типы для неточной финансовой математики, еще не зная о проблеме, и их "код" кто-то же ревьювит и пропускает в прод, все эти синьоры и тимлиды с опытом работы до 6 лет :)

        А если открыть классиков вроде K&R, то можно с изумлением увидеть и первопричину явления.


    1. mosinnik
      20.10.2026 16:40

      так давайте спросим @moderator


  1. RumataEstora
    20.10.2026 16:40

    Так много раз упомянули стандарт IEEE 754, а что это такое и как оно работает не рассказали и не показали.

    Вот нашел рабочее демо (не мое, лежит давно в закладках) - https://www.h-schmidt.net/FloatConverter/IEEE754.html


  1. mcsimm
    20.10.2026 16:40

    Почему у Вас так странно написаны года?

    принятый в 1 985 году. В 1 989 году


    1. Kiridan
      20.10.2026 16:40

      Ну вот четыре года метались, сомневались, откладывали, а потом всë-таки набрались духу и приняли.


    1. saege5b
      20.10.2026 16:40

      Не только у него, не только на хабре, не только года.

      Это или в новом семействе ИИ, в некоторых локалях по умолчанию в длинных числах ставятся пробелы между триплетами, или в свежей версии какой-нибудь библиотеки коррекции правила съехали.


  1. LAutour
    20.10.2026 16:40

    а индустрия раскололась на двоичный, восьмеричный и шестнадцатеричный лагеря

    Что за бред?