Самое важное из мира AI — в канале MAX AgentLabОтдельные разборы, инструменты и практические схемы Читайте Agent Lab в Telegram Разборы, кейсы и новости о практических AI-агентах без лишней воды.

Как не дать ИИ использовать float для денег

Как не дать ИИ использовать float для денег
Temporary fallback cover; replace in editorial pass.

Уровень: средний · Время чтения: до 12 минут · Обновлено: 18 октября 2026

Попросите ИИ-ассистента «посчитать сумму заказа со скидкой и НДС», и почти наверняка получите код на обычных числах: price * 0.8 * 1.2, а в конце toFixed(2). Такой код проходит ревью, работает на демо-данных и ошибается на копейку там, где этого никто не ждёт. Причина в том, что number в JavaScript — это двоичное число с плавающей точкой (IEEE 754 double), а большинство десятичных дробей вроде 0,1 в двоичном виде точно не представимы.

Проверить это можно в любой консоли Node.js:

> 0.1 + 0.2
0.30000000000000004
> (1.005).toFixed(2)
'1.00'

Просьбы «не используй float» в промпте недостаточно: агент забудет её через пару итераций или обойдёт через any. Нужен код, который не даст ошибиться физически. В этом руководстве мы сделаем три слоя защиты:

  1. Тип Money на bigint в минимальных единицах (копейках, центах), у которого вообще нет входа для number.
  2. Тесты компиляции на @ts-expect-error: сборка падает, если кто-то (человек или агент) ослабит сигнатуры и разрешит float.
  3. Тесты выполнения: float, пришедший из JSON, через as any или из JavaScript-кода, отклоняется с TypeError.

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

Что понадобится

Шаг 1. Создаём проект

mkdir money-guard && cd money-guard
npm init -y
npm install --save-dev typescript@5 @types/node
mkdir src

Замените содержимое package.json на минимальное:

{
  "name": "money-guard",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "tsc -p .",
    "test": "npm run build && node --test \"dist/**/*.test.js\""
  },
  "devDependencies": {
    "@types/node": "*",
    "typescript": "^5"
  }
}

Версии в devDependencies оставьте те, что записал npm install; здесь они даны условно.

Создайте tsconfig.json. bigint требует цели не ниже ES2020, а проверка #field in obj и Object.hasOwn — ES2022:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "rootDir": "src",
    "outDir": "dist",
    "types": ["node"],
    "skipLibCheck": true
  },
  "include": ["src"]
}

Шаг 2. Пишем тип Money

Главная идея: сумма хранится как целое число минимальных единиц в bigint, а создать её можно только из строки ("19.99") или из bigint. Принимающего number метода нет вообще, поэтому сгенерированный ассистентом Money.parse(19.99, 'RUB') не скомпилируется.

Файл src/money.ts:

export type Currency = 'RUB' | 'USD' | 'EUR' | 'JPY';

// Сколько знаков у минимальной единицы: копейки, центы; у иены их нет.
const SCALE: Readonly<Record<Currency, number>> = { RUB: 2, USD: 2, EUR: 2, JPY: 0 };
const DECIMAL = /^(-)?(\d+)(?:\.(\d+))?$/;

function assertBigInt(value: unknown, name: string): asserts value is bigint {
  if (typeof value !== 'bigint') {
    throw new TypeError(`${name}: ожидается bigint, получено ${typeof value}`);
  }
}

function assertCurrency(value: unknown): asserts value is Currency {
  if (typeof value !== 'string' || !Object.hasOwn(SCALE, value)) {
    throw new RangeError(`Неизвестная валюта: ${String(value)}`);
  }
}

// Деление с банковским округлением (половина — к чётному). den > 0.
function divRoundHalfEven(n: bigint, den: bigint): bigint {
  const q = n / den; // bigint-деление отбрасывает дробь в сторону нуля
  const r = n % den;
  const twice = (r < 0n ? -r : r) * 2n;
  if (twice < den) return q;
  if (twice > den || q % 2n !== 0n) return q + (n < 0n ? -1n : 1n);
  return q;
}

export class Money {
  // Приватное поле делает тип номинальным: объект-подделка { minor, currency } не пройдёт.
  readonly #nominal = true;
  readonly minor: bigint;
  readonly currency: Currency;

  private constructor(minor: bigint, currency: Currency) {
    assertBigInt(minor, 'minor');
    assertCurrency(currency);
    this.minor = minor;
    this.currency = currency;
    Object.freeze(this);
  }

  static isMoney(value: unknown): value is Money {
    return typeof value === 'object' && value !== null && #nominal in value;
  }

  static fromMinor(minor: bigint, currency: Currency): Money {
    return new Money(minor, currency);
  }

  static parse(amount: string, currency: Currency): Money {
    if (typeof amount !== 'string') {
      throw new TypeError(`Money.parse: ожидается строка, получено ${typeof amount}`);
    }
    assertCurrency(currency);
    const match = DECIMAL.exec(amount);
    if (!match) throw new RangeError(`Некорректная сумма: "${amount}"`);
    const [, sign, whole, fraction = ''] = match;
    const scale = SCALE[currency];
    if (fraction.length > scale) {
      throw new RangeError(`${currency}: допускается не больше ${scale} знаков после точки`);
    }
    const minor = BigInt(whole + fraction.padEnd(scale, '0'));
    return new Money(sign ? -minor : minor, currency);
  }

  add(other: Money): Money {
    this.#assertSameCurrency(other);
    return new Money(this.minor + other.minor, this.currency);
  }

  subtract(other: Money): Money {
    this.#assertSameCurrency(other);
    return new Money(this.minor - other.minor, this.currency);
  }

  // Умножение на дробь num/den: скидка 15% — это multiplyRatio(85n, 100n).
  multiplyRatio(num: bigint, den: bigint): Money {
    assertBigInt(num, 'num');
    assertBigInt(den, 'den');
    if (den <= 0n) throw new RangeError('den должен быть больше нуля');
    return new Money(divRoundHalfEven(this.minor * num, den), this.currency);
  }

  // Делит сумму по долям так, чтобы сумма частей точно совпала с исходной.
  allocate(ratios: readonly bigint[]): Money[] {
    if (ratios.length === 0 || ratios.some((r) => typeof r !== 'bigint' || r < 0n)) {
      throw new TypeError('allocate: нужен непустой список неотрицательных bigint');
    }
    const total = ratios.reduce((a, b) => a + b, 0n);
    if (total === 0n) throw new RangeError('allocate: сумма долей равна нулю');
    const parts = ratios.map((r) => (this.minor * r) / total);
    let rest = this.minor - parts.reduce((a, b) => a + b, 0n);
    const step = rest < 0n ? -1n : 1n;
    for (let i = 0; rest !== 0n; i = (i + 1) % parts.length) {
      if (ratios[i] === 0n) continue;
      parts[i] += step;
      rest -= step;
    }
    return parts.map((p) => new Money(p, this.currency));
  }

  toString(): string {
    const scale = SCALE[this.currency];
    const negative = this.minor < 0n;
    const digits = (negative ? -this.minor : this.minor).toString().padStart(scale + 1, '0');
    const whole = digits.slice(0, digits.length - scale);
    const fraction = digits.slice(digits.length - scale);
    return `${negative ? '-' : ''}${whole}${scale ? '.' + fraction : ''}`;
  }

  // В JSON сумма уходит строкой, а не числом.
  toJSON(): { amount: string; currency: Currency } {
    return { amount: this.toString(), currency: this.currency };
  }

  // Запрещает неявное превращение в число: money * 1.2, Number(money).
  [Symbol.toPrimitive](hint: string): string {
    if (hint === 'string') return this.toString();
    throw new TypeError('Money нельзя неявно превратить в число: используйте методы Money');
  }

  #assertSameCurrency(other: Money): void {
    if (!Money.isMoney(other)) throw new TypeError('Ожидается Money');
    if (other.currency !== this.currency) {
      throw new RangeError(`Валюты не совпадают: ${this.currency} и ${other.currency}`);
    }
  }
}

Обратите внимание на решения, которые защищают именно от ошибок сгенерированного кода:

Шаг 3. Тесты компиляции: float не должен собираться

Директива // @ts-expect-error говорит компилятору: «следующая строка обязана содержать ошибку». Если ошибки нет, tsc сообщает TS2578: Unused '@ts-expect-error' directive и сборка падает. Так мы закрепляем запрет: если агент «починит» тип и разрешит number, сломается сборка, а не расчёт в проде.

Файл src/money.typecheck.ts. Функция ни разу не вызывается: этот файл нужен только компилятору.

import { Money } from './money.js';

export function typeChecks(): void {
  const rub = Money.parse('1.00', 'RUB');

  // @ts-expect-error: float вместо строки
  Money.parse(0.1, 'RUB');

  // @ts-expect-error: number вместо bigint
  Money.fromMinor(10, 'RUB');

  // @ts-expect-error: дробный множитель вместо пары bigint
  rub.multiplyRatio(1.2, 1n);

  // @ts-expect-error: сложение с числом
  rub.add(0.5);

  // @ts-expect-error: арифметика над Money напрямую
  rub * 1.2;

  // @ts-expect-error: подделка структуры не является Money
  rub.add({ minor: 1n, currency: 'RUB' });

  // @ts-expect-error: неизвестная валюта
  Money.parse('1.00', 'BTC');

  // @ts-expect-error: конструктор закрыт, только parse/fromMinor
  new Money(1n, 'RUB');
}

Шаг 4. Тесты выполнения: float не должен пройти и в обход типов

Компилятор не видит данные из сети, JSON.parse и код, где агент поставил as any. Эти пути закрывают тесты выполнения. Файл src/money.test.ts:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Money } from './money.js';

test('двоичный float действительно теряет точность', () => {
  assert.notEqual(0.1 + 0.2, 0.3);
});

test('0.10 + 0.20 = 0.30 без потерь', () => {
  const sum = Money.parse('0.10', 'RUB').add(Money.parse('0.20', 'RUB'));
  assert.equal(sum.toString(), '0.30');
  assert.equal(sum.minor, 30n);
});

test('float в обход типов отклоняется', () => {
  const rub = Money.parse('1.00', 'RUB');
  assert.throws(() => Money.parse(0.1 as unknown as string, 'RUB'), TypeError);
  assert.throws(() => Money.fromMinor(10 as unknown as bigint, 'RUB'), TypeError);
  assert.throws(() => rub.multiplyRatio(1.2 as unknown as bigint, 1n), TypeError);
  assert.throws(() => rub.add(0.5 as unknown as Money), TypeError);
});

test('число из JSON внешнего API не проходит', () => {
  const payload = JSON.parse('{"amount": 19.99}');
  assert.throws(() => Money.parse(payload.amount, 'RUB'), TypeError);
});

test('неявное приведение к числу запрещено', () => {
  const rub = Money.parse('10.00', 'RUB');
  assert.throws(() => Number(rub), TypeError);
  assert.throws(() => (rub as unknown as number) * 1.2, TypeError);
  assert.equal(`${rub}`, '10.00');
});

test('лишние знаки и экспонента отклоняются', () => {
  assert.throws(() => Money.parse('0.001', 'RUB'), RangeError);
  assert.throws(() => Money.parse('1.5', 'JPY'), RangeError);
  assert.throws(() => Money.parse('1e3', 'RUB'), RangeError);
});

test('разные валюты не складываются', () => {
  assert.throws(
    () => Money.parse('1.00', 'RUB').add(Money.parse('1.00', 'USD')),
    RangeError,
  );
});

test('банковское округление половины', () => {
  assert.equal(Money.parse('0.05', 'RUB').multiplyRatio(1n, 2n).toString(), '0.02');
  assert.equal(Money.parse('0.15', 'RUB').multiplyRatio(1n, 2n).toString(), '0.08');
});

test('распределение без потери копейки', () => {
  const parts = Money.parse('100.00', 'RUB').allocate([1n, 1n, 1n]);
  assert.deepEqual(parts.map(String), ['33.34', '33.33', '33.33']);
  const total = parts.reduce((a, b) => a.add(b));
  assert.equal(total.toString(), '100.00');
});

test('в JSON сумма уходит строкой', () => {
  assert.equal(
    JSON.stringify(Money.parse('19.99', 'RUB')),
    '{"amount":"19.99","currency":"RUB"}',
  );
});

Касты as unknown as ... здесь намеренные: они моделируют то, как сгенерированный код обходит типы. В рабочем коде такие касты вокруг денег — повод вернуть изменение на доработку.

Шаг 5. Линтер и инструкция для агента

Типы защищают границы Money, но агент может посчитать сумму на number ещё до того, как она попадёт в тип. Чтобы ловить характерные шаблоны, добавьте правило no-restricted-syntax:

npm install --save-dev eslint typescript-eslint

Файл eslint.config.js:

import tseslint from 'typescript-eslint';

const moneyBans = [
  {
    selector: "CallExpression[callee.name='parseFloat']",
    message: 'parseFloat для денег запрещён: используйте Money.parse',
  },
  {
    selector: "MemberExpression[property.name='toFixed']",
    message: 'toFixed округляет двоичный float: используйте Money.toString',
  },
  {
    selector: "CallExpression[callee.object.name='Math'][callee.property.name='round']",
    message: 'Math.round над суммой: используйте Money.multiplyRatio',
  },
];

export default [
  {
    files: ['src/**/*.ts'],
    languageOptions: { parser: tseslint.parser },
    rules: { 'no-restricted-syntax': ['error', ...moneyBans] },
  },
];

Если в проекте Math.round нужен за пределами денежного кода, сузьте files до каталога с биллингом.

Добавьте в файл инструкций для агента (например, AGENTS.md или CLAUDE.md, в зависимости от инструмента) короткое правило со ссылкой на проверки:

## Деньги
- Любые денежные суммы — только через src/money.ts (Money).
- Запрещены number, parseFloat, toFixed и Math.round для сумм.
- Проценты передавай дробью bigint: multiplyRatio(20n, 100n).
- Не ослабляй сигнатуры Money и не удаляй @ts-expect-error в money.typecheck.ts.
- Перед завершением задачи запусти: npm test && npx eslint src

Инструкция ускоряет работу (агент сразу пишет правильно), но гарантией не является. Гарантии дают сборка и тесты из шагов 3–4.

Проверка результата

  1. Запустите полный прогон:
    npm test
    npx eslint src
    Ожидаемо: tsc завершается без ошибок, в сводке node --test значение fail равно 0, ESLint ничего не выводит.
  2. Мутация типов. Временно замените в money.ts сигнатуру на static parse(amount: string | number, currency: Currency) и запустите npm run build. Должна появиться ошибка TS2578: Unused '@ts-expect-error' directive в money.typecheck.ts. Это значит, что компиляционный слой работает. Верните сигнатуру.
  3. Мутация выполнения. Временно удалите проверку typeof amount !== 'string' в parse. Сборка пройдёт (типы не изменились), но упадут тесты «float в обход типов отклоняется» и «число из JSON внешнего API не проходит»: регулярное выражение молча приведёт 0.1 к строке "0.1". Так видно, зачем нужен второй слой. Верните проверку.
  4. Мутация линтера. Добавьте в любой файл из src строку const x = (19.99 * 1.2).toFixed(2); и запустите npx eslint src. Должна появиться ошибка с вашим сообщением про toFixed. Удалите строку.

Мутационные проверки стоит повторять при каждом изменении money.ts: тест, который ни разу не падал, ещё не доказал, что он что-то ловит.

Типовые ошибки

Ограничения подхода

Итог

Ответ на вопрос, как не дать ИИ использовать float для денег, звучит так: не полагаться на вежливость модели. Сделайте тип, у которого нет входа для number, закрепите его тестами компиляции на @ts-expect-error, продублируйте проверки во время выполнения и прогоняйте всё это в CI. Тогда ошибка агента превращается в упавшую сборку, а не в расхождение в отчёте через месяц.

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