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

Попросите ИИ-ассистента «посчитать сумму заказа со скидкой и НДС», и почти наверняка получите код на обычных числах: 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. Нужен код, который не даст ошибиться физически. В этом руководстве мы сделаем три слоя защиты:
- Тип
Moneyнаbigintв минимальных единицах (копейках, центах), у которого вообще нет входа дляnumber. - Тесты компиляции на
@ts-expect-error: сборка падает, если кто-то (человек или агент) ослабит сигнатуры и разрешит float. - Тесты выполнения: float, пришедший из JSON, через
as anyили из JavaScript-кода, отклоняется сTypeError.
Дополнительно подключим правило линтера и инструкцию для агента. Всё ниже — учебный пример, а не готовая библиотека: таблицу валют и правила округления вы зададите под свой домен.
Что понадобится
- Node.js 22 или новее (нужен встроенный
node:testс поддержкой glob-шаблонов). - TypeScript 5.x.
- Пустой каталог для эксперимента — команды ниже ничего не трогают за его пределами.
Шаг 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}`);
}
}
}
Обратите внимание на решения, которые защищают именно от ошибок сгенерированного кода:
- Каждый публичный вход проверяет тип и при компиляции, и во время выполнения. Типы TypeScript стираются, а вот
typeofникуда не денется. - Умножения на
numberнет. Процент выражается дробью из двухbigint, а правило округления зафиксировано в одной функции, а не разбросано поMath.roundв разных файлах. allocateраскладывает остаток по долям, чтобы «100 рублей на троих» дало 33,34 + 33,33 + 33,33, а не 99,99.
Шаг 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.
Проверка результата
- Запустите полный прогон:
Ожидаемо:npm test npx eslint srctscзавершается без ошибок, в сводкеnode --testзначениеfailравно 0, ESLint ничего не выводит. - Мутация типов. Временно замените в
money.tsсигнатуру наstatic parse(amount: string | number, currency: Currency)и запуститеnpm run build. Должна появиться ошибкаTS2578: Unused '@ts-expect-error' directiveвmoney.typecheck.ts. Это значит, что компиляционный слой работает. Верните сигнатуру. - Мутация выполнения. Временно удалите проверку
typeof amount !== 'string'вparse. Сборка пройдёт (типы не изменились), но упадут тесты «float в обход типов отклоняется» и «число из JSON внешнего API не проходит»: регулярное выражение молча приведёт0.1к строке"0.1". Так видно, зачем нужен второй слой. Верните проверку. - Мутация линтера. Добавьте в любой файл из
srcстрокуconst x = (19.99 * 1.2).toFixed(2);и запуститеnpx eslint src. Должна появиться ошибка с вашим сообщением проtoFixed. Удалите строку.
Мутационные проверки стоит повторять при каждом изменении money.ts: тест, который ни разу не падал, ещё не доказал, что он что-то ловит.
Типовые ошибки
- Копейки в
number. Целые доNumber.MAX_SAFE_INTEGER(253−1) хранятся точно, но первое же деление на процент снова даёт дробный float.bigintне позволит смешать его сnumberбез явного преобразования: операция10n * 1.2выбрасываетTypeError. - Округление через
toFixed. Пример с(1.005).toFixed(2)выше показывает, что округляется уже искажённое двоичное значение. - Сумма числом в JSON.
{"amount": 19.99}превращается во float ещё вJSON.parse. Договоритесь с API о строках или минимальных единицах. Учтите и обратное направление:JSON.stringifyна голомbigintвыбрасываетTypeError, поэтому уMoneyестьtoJSON. - FLOAT или REAL в базе. Тип в коде не спасёт, если колонка хранит двоичную дробь. Используйте
NUMERIC/DECIMALс фиксированной точностью или целые минимальные единицы, а драйвер настройте так, чтобы он возвращал строку, а неnumber. - Округление на каждом шаге. Цепочка «скидка → округлить → налог → округлить» и одна дробь
multiplyRatio(85n * 120n, 100n * 100n)могут дать разный результат. Какой вариант правильный, решают бизнес-правила, но порядок должен быть явным и покрыт тестом. - Удалённый
@ts-expect-error. Агент, увидевший «ошибку» в typecheck-файле, может её «исправить». Защищайте этот файл через CODEOWNERS или правило ревью.
Ограничения подхода
- Таблица
SCALEучебная. В ISO 4217 есть валюты с тремя знаками после запятой и другие особые случаи; для реального проекта берите таблицу из проверенного источника. - Курсы валют и налоговые ставки с большим числом знаков удобнее хранить как отдельный десятичный или рациональный тип, а не как
bigint-дробь, собранную вручную. - Правило ESLint синтаксическое: оно ловит
toFixedпо имени, но не отличит денежную переменную от координаты на карте и не поймает float, который пришёл через сторонний модуль. - Проверки времени выполнения защищают только входы
Money. Если сумма посчитана на float заранее и передана строкой"23.987999999999996", её остановит лишь проверка числа знаков. - Готовые библиотеки десятичной арифметики тоже решают задачу; самописный тип полезен, когда нужен узкий API без входа для
number. Выбор зависит от проекта. - Подход показан для TypeScript. В других языках идея та же: десятичный или целочисленный тип, отказ от float на границе (в Python, например,
decimal.Decimalплюс явная проверкаisinstance(x, float)) и тест, который это фиксирует.
Итог
Ответ на вопрос, как не дать ИИ использовать float для денег, звучит так: не полагаться на вежливость модели. Сделайте тип, у которого нет входа для number, закрепите его тестами компиляции на @ts-expect-error, продублируйте проверки во время выполнения и прогоняйте всё это в CI. Тогда ошибка агента превращается в упавшую сборку, а не в расхождение в отчёте через месяц.
Другие практические материалы об агентах и проверке их кода собраны в разделе руководств, а термины из статьи объяснены в глоссарии.