Как не дать ИИ использовать float для денег: an exact money type and tests that reject floats

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

Level: intermediate · Reading time: 30 minutes · Updated: 18 October 2026 · Stack: TypeScript, Node.js 22+

Ask a coding assistant for a cart total and you will often get price: number, a reduce and a toFixed(2) at the end. It looks fine, passes a quick manual check, and gets money wrong in ways that only show up in comparisons, tie-rounding and long sums. Writing "please don't use float" in a prompt doesn't fix this. What works is a money type the compiler won't let a float into, runtime checks that catch floats smuggled through any or JSON, and a test suite that fails the moment someone weakens either. This guide builds all three. Allow about 30 minutes.

1. The problem: plausible code, wrong cents

Here is an illustrative example of what an AI coding assistant commonly produces for "write a function that returns the cart total with tax". It's a composite of a common pattern, not a quote from a real incident:

// examples/ai-cart.ts — what an assistant typically produces
type LineItem = { name: string; price: number; qty: number };

export function cartTotal(items: LineItem[], taxRate: number): string {
  const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0);
  const tax = subtotal * taxRate;
  return (subtotal + tax).toFixed(2);
}

Nothing here is a syntax error, and for many inputs the printed result is right. The trouble comes from three properties of JavaScript's number, a 64-bit binary float:

Assistants reproduce this pattern because it dominates tutorials and snippets, and because it's the shortest code that "works". The fix isn't a better prompt alone. The fix is to make the wrong code fail to compile, fail at runtime, and fail in CI.

2. Design: what "exact money" means here

The design choices, and why we made each one:

  1. Store minor units as bigint. 19.99 USD is stored as 1999n cents. Integers are exact, and bigint has no 253 ceiling. bigint and number also can't be mixed in arithmetic (1n + 1 throws TypeError), which works in our favour.
  2. Accept amounts only as decimal strings or bigint. A number argument is a type error at compile time and a TypeError at runtime.
  3. Each currency defines its exponent. USD and EUR use 2 decimal places, JPY uses 0, KWD uses 3 (per ISO 4217). Extra precision is rejected rather than silently rounded.
  4. Make the branded type impossible to forge. A plain { minor: 1999n, currency: "USD" } object doesn't type-check as Money. Values come only from the constructors.
  5. Rounding is always explicit. Any operation that can produce a fraction of a minor unit takes a mandatory rounding mode: half-even, half-up or truncate.
  6. Allocation never loses a cent. Splitting 100.00 three ways gives 33.34 + 33.33 + 33.33.
  7. JSON carries strings. {"amount":"19.99","currency":"USD"}, never 19.99.

3. Project setup

You need Node.js 22.6 or newer, which can run TypeScript files directly through type stripping, and TypeScript 5.8 or newer for the erasableSyntaxOnly option. The only other dependency is @types/node.

mkdir money-guard && cd money-guard
npm init -y
npm pkg set type=module
npm install --save-dev typescript@^5.8 @types/node
mkdir -p src test scripts examples

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}

erasableSyntaxOnly makes the compiler reject syntax that Node's type stripping can't run, such as enum or constructor parameter properties. The examples folder is deliberately excluded, because it will hold the "bad" code we want the tools to flag.

Add the scripts:

npm pkg set scripts.typecheck="tsc -p ."
npm pkg set scripts.test="node --experimental-strip-types --test \"test/*.test.ts\""
npm pkg set scripts.lint:money="node scripts/forbid-float-money.mjs src"
npm pkg set scripts.check="npm run typecheck && npm run lint:money && npm test"

On Node 23.6+ and recent 22.x releases, type stripping is on by default and the flag is harmless. On Node 22.6–22.17 you need the flag. Expect an "experimental" warning on some versions.

4. The money type

Create src/money.ts. The whole module is about 130 lines and has no dependencies.

// src/money.ts
declare const MoneyBrand: unique symbol;

export const EXPONENT = {
  USD: 2,
  EUR: 2,
  RUB: 2,
  JPY: 0,
  KWD: 3,
} as const;

export type Currency = keyof typeof EXPONENT;
export type RoundingMode = "half-even" | "half-up" | "truncate";

export type Money = {
  readonly minor: bigint;
  readonly currency: Currency;
  readonly [MoneyBrand]: true;
};

export type MoneyJSON = { amount: string; currency: Currency };

const DECIMAL = /^(-)?(\d+)(?:\.(\d+))?$/;

function assertCurrency(currency: unknown): asserts currency is Currency {
  if (typeof currency !== "string" || !Object.hasOwn(EXPONENT, currency)) {
    throw new TypeError(`Unknown currency: ${String(currency)}`);
  }
}

function assertSameCurrency(a: Money, b: Money): void {
  if (a.currency !== b.currency) {
    throw new TypeError(`Currency mismatch: ${a.currency} vs ${b.currency}`);
  }
}

// --- constructors -------------------------------------------------------

export function fromMinor(minor: bigint, currency: Currency): Money {
  if (typeof minor !== "bigint") {
    throw new TypeError(`Money minor units must be bigint, got ${typeof minor}`);
  }
  assertCurrency(currency);
  return Object.freeze({ minor, currency }) as Money;
}

export function parseMoney(input: string, currency: Currency): Money {
  if (typeof input !== "string") {
    throw new TypeError(`Money amounts must be decimal strings, got ${typeof input}`);
  }
  assertCurrency(currency);
  const match = DECIMAL.exec(input);
  if (!match) throw new TypeError(`Not a decimal amount: "${input}"`);
  const [, sign, whole, fraction = ""] = match;
  const exponent = EXPONENT[currency];
  if (fraction.length > exponent) {
    throw new RangeError(
      `"${input}" has more than ${exponent} fraction digits for ${currency}`,
    );
  }
  const minor = BigInt(whole + fraction.padEnd(exponent, "0"));
  return fromMinor(sign ? -minor : minor, currency);
}

// --- formatting and JSON ------------------------------------------------

export function format(m: Money): string {
  const exponent = EXPONENT[m.currency];
  const negative = m.minor < 0n;
  const digits = (negative ? -m.minor : m.minor)
    .toString()
    .padStart(exponent + 1, "0");
  const whole = digits.slice(0, digits.length - exponent);
  const fraction = digits.slice(digits.length - exponent);
  const body = exponent === 0 ? whole : `${whole}.${fraction}`;
  return negative ? `-${body}` : body;
}

export function toJSON(m: Money): MoneyJSON {
  return { amount: format(m), currency: m.currency };
}

export function fromJSON(value: unknown): Money {
  if (typeof value !== "object" || value === null) {
    throw new TypeError("Money JSON must be an object");
  }
  const { amount, currency } = value as Record<string, unknown>;
  if (typeof amount !== "string") {
    throw new TypeError(`Money JSON amount must be a string, got ${typeof amount}`);
  }
  assertCurrency(currency);
  return parseMoney(amount, currency);
}

// --- arithmetic ---------------------------------------------------------

export function add(a: Money, b: Money): Money {
  assertSameCurrency(a, b);
  return fromMinor(a.minor + b.minor, a.currency);
}

export function subtract(a: Money, b: Money): Money {
  assertSameCurrency(a, b);
  return fromMinor(a.minor - b.minor, a.currency);
}

export function sum(items: readonly Money[], currency: Currency): Money {
  return items.reduce((acc, m) => add(acc, m), fromMinor(0n, currency));
}

export function multiplyQty(m: Money, qty: bigint): Money {
  if (typeof qty !== "bigint") {
    throw new TypeError(`Quantity must be bigint, got ${typeof qty}`);
  }
  return fromMinor(m.minor * qty, m.currency);
}

function divideRounded(n: bigint, d: bigint, mode: RoundingMode): bigint {
  if (d === 0n) throw new RangeError("Division by zero");
  if (d < 0n) {
    n = -n;
    d = -d;
  }
  const q = n / d; // bigint division truncates toward zero
  const r = n % d; // remainder has the sign of n
  if (r === 0n) return q;
  const away = n < 0n ? -1n : 1n;
  const twice = 2n * (r < 0n ? -r : r);
  switch (mode) {
    case "truncate":
      return q;
    case "half-up": // half away from zero
      return twice >= d ? q + away : q;
    case "half-even":
      if (twice > d) return q + away;
      if (twice < d) return q;
      return q % 2n === 0n ? q : q + away;
  }
}

function parseRate(rate: string): { num: bigint; den: bigint } {
  if (typeof rate !== "string") {
    throw new TypeError(`Rates must be decimal strings, got ${typeof rate}`);
  }
  const match = DECIMAL.exec(rate);
  if (!match) throw new TypeError(`Not a decimal rate: "${rate}"`);
  const [, sign, whole, fraction = ""] = match;
  const num = BigInt(whole + fraction);
  return { num: sign ? -num : num, den: 10n ** BigInt(fraction.length) };
}

export function multiply(m: Money, rate: string, mode: RoundingMode): Money {
  const { num, den } = parseRate(rate);
  return fromMinor(divideRounded(m.minor * num, den, mode), m.currency);
}

export function allocate(m: Money, weights: readonly bigint[]): Money[] {
  if (weights.length === 0) throw new RangeError("No weights given");
  if (weights.some((w) => typeof w !== "bigint" || w < 0n)) {
    throw new TypeError("Weights must be non-negative bigint values");
  }
  const total = weights.reduce((a, b) => a + b, 0n);
  if (total === 0n) throw new RangeError("Weights sum to zero");

  const shares = weights.map((w) => (m.minor * w) / total);
  let remainder = m.minor - shares.reduce((a, b) => a + b, 0n);
  const step = remainder < 0n ? -1n : 1n;
  for (let i = 0; remainder !== 0n; i = (i + 1) % shares.length) {
    if (weights[i] === 0n) continue;
    shares[i] += step;
    remainder -= step;
  }
  return shares.map((minor) => fromMinor(minor, m.currency));
}

A few details matter more than they look:

5. Compile-time tests: @ts-expect-error as an assertion

A compile-time test is a file of calls that must not type-check. Each one is marked with // @ts-expect-error. If a later change makes any of those lines valid, for example someone widens input: string to string | number because an assistant suggested it, the directive is unused and tsc fails with error TS2578 ("Unused '@ts-expect-error' directive"). So the type contract itself is under test.

Create test/types.check.ts. It's type-checked by tsc and never run; its name doesn't match the *.test.ts pattern.

// test/types.check.ts — compiled by `tsc`, never executed
import { add, fromMinor, multiply, parseMoney, type Money } from "../src/money.ts";

export function compileTimeGuards(): void {
  const price = parseMoney("19.99", "USD");

  // @ts-expect-error a float literal is not a decimal string
  parseMoney(19.99, "USD");

  // @ts-expect-error minor units must be bigint, not number
  fromMinor(1999, "USD");

  // @ts-expect-error rates are decimal strings, not floats
  multiply(price, 0.0825, "half-even");

  // @ts-expect-error the rounding mode is mandatory
  multiply(price, "0.0825");

  // @ts-expect-error Money is not a number: no arithmetic operators
  const doubled = price * 2;

  // @ts-expect-error a plain object cannot impersonate Money (brand missing)
  const forged: Money = { minor: 1999n, currency: "USD" };

  // @ts-expect-error unknown currency code
  parseMoney("1.00", "XYZ");

  add(price, price); // the legitimate path still compiles
}

Run it:

npm run typecheck

Expected: no output and exit code 0. Each marked line has the error it claims to have, and the final add call is valid.

6. Runtime tests: floats that got past the compiler

The compiler can't see through any, as unknown as, JSON.parse, untyped JavaScript callers or a form field converted with Number(). The runtime suite proves that floats arriving through those routes are rejected too. Create test/money.test.ts:

// test/money.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import {
  add, allocate, format, fromJSON, fromMinor,
  multiply, multiplyQty, parseMoney, sum, toJSON,
} from "../src/money.ts";

const usd = (s: string) => parseMoney(s, "USD");

test("baseline: binary floats cannot represent cents exactly", () => {
  assert.notEqual(0.1 + 0.2, 0.3);
  let tenDimes = 0;
  for (let i = 0; i < 10; i++) tenDimes += 0.1;
  assert.notEqual(tenDimes, 1);
  assert.equal(Math.round(1.005 * 100) / 100, 1); // "half up" went down
});

test("decimal strings add exactly", () => {
  assert.equal(format(add(usd("0.10"), usd("0.20"))), "0.30");
  const dimes = Array.from({ length: 10 }, () => usd("0.10"));
  assert.equal(format(sum(dimes, "USD")), "1.00");
});

test("round-trips every cent value from 0.00 to 99.99", () => {
  for (let cents = 0n; cents < 10_000n; cents++) {
    const m = fromMinor(cents, "USD");
    assert.equal(parseMoney(format(m), "USD").minor, cents);
  }
});

test("rejects floats at runtime even when types are bypassed", () => {
  const smuggled: unknown = 19.99;
  assert.throws(() => parseMoney(smuggled as string, "USD"), TypeError);
  assert.throws(() => fromMinor(1999 as unknown as bigint, "USD"), TypeError);
  assert.throws(
    () => multiply(usd("1.00"), 0.5 as unknown as string, "half-even"),
    TypeError,
  );
  assert.throws(() => multiplyQty(usd("1.00"), 3 as unknown as bigint), TypeError);
  assert.throws(() => fromJSON({ amount: 19.99, currency: "USD" }), TypeError);
  assert.throws(() => fromJSON(JSON.parse('{"amount":19.99,"currency":"USD"}')), TypeError);
});

test("rejects more precision than the currency allows", () => {
  assert.throws(() => usd("1.005"), RangeError);
  assert.throws(() => parseMoney("100.5", "JPY"), RangeError);
  assert.equal(format(parseMoney("0.125", "KWD")), "0.125");
  assert.equal(format(parseMoney("1500", "JPY")), "1500");
});

test("rejects malformed amounts", () => {
  for (const bad of ["", "1,00", "1e2", "+1.00", " 1.00", "1.", ".5", "NaN"]) {
    assert.throws(() => usd(bad), TypeError, `accepted ${JSON.stringify(bad)}`);
  }
});

test("multiplication requires and respects a rounding mode", () => {
  // 19.99 * 0.0825 = 1.649175 -> 1.65
  assert.equal(format(multiply(usd("19.99"), "0.0825", "half-even")), "1.65");
  // exact tie: 0.25 * 0.5 = 0.125
  assert.equal(format(multiply(usd("0.25"), "0.5", "half-even")), "0.12");
  assert.equal(format(multiply(usd("0.25"), "0.5", "half-up")), "0.13");
  assert.equal(format(multiply(usd("0.25"), "0.5", "truncate")), "0.12");
  assert.equal(format(multiply(usd("-0.25"), "0.5", "half-up")), "-0.13");
  assert.equal(format(multiply(usd("-0.25"), "0.5", "half-even")), "-0.12");
});

test("allocation never loses a cent", () => {
  const parts = allocate(usd("100.00"), [1n, 1n, 1n]);
  assert.deepEqual(parts.map(format), ["33.34", "33.33", "33.33"]);
  assert.equal(format(sum(parts, "USD")), "100.00");

  const refund = allocate(usd("-0.05"), [1n, 0n, 1n]);
  assert.deepEqual(refund.map(format), ["-0.03", "0.00", "-0.02"]);
});

test("currency mismatch is an error, not a conversion", () => {
  assert.throws(() => add(usd("1.00"), parseMoney("1.00", "EUR")), TypeError);
});

test("JSON uses strings, never numbers", () => {
  const json = JSON.stringify(toJSON(usd("19.99")));
  assert.equal(json, '{"amount":"19.99","currency":"USD"}');
  assert.equal(format(fromJSON(JSON.parse(json))), "19.99");
  // raw bigint cannot be serialized by accident
  assert.throws(() => JSON.stringify({ total: 1999n }), TypeError);
});

Run it:

npm test

Expected: every test reports ok, and the summary shows zero failures. To check the less obvious numbers yourself: 1999 × 825 = 1,649,175, and dividing by 10,000 leaves a remainder of 9,175, which is above half, so the result rounds to 165 cents. For −5 cents split by weights [1, 0, 1]: truncation gives −2, 0, −2. The remaining −1 goes to the first non-zero weight, giving −3, 0, −2.

The baseline test is intentional. It documents in executable form why the module exists. If someone ever asks "do we really need this?", the answer is in the test output.

7. A cheap static gate for AI-generated code elsewhere in the repo

The type and the tests protect the money module. They don't stop an assistant from writing price: number in a new file that never imports it. A small scanner catches the most common patterns. Create scripts/forbid-float-money.mjs:

// scripts/forbid-float-money.mjs
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";

const roots = process.argv.slice(2);
if (roots.length === 0) roots.push("src");

const RULES = [
  [/\bparseFloat\s*\(/, "parseFloat() on a money path"],
  [/\.toFixed\s*\(/, "toFixed() rounds a binary float"],
  [/\bMath\.(round|floor|ceil)\s*\(/, "Math rounding of a float"],
  [/\b(amount|price|total|subtotal|balance|cost|fee|tax)\w*\??\s*:\s*number\b/i,
    "money-like field typed as number"],
];
const ALLOW = "money-float-ok:";

let failures = 0;
for (const root of roots) {
  for (const rel of readdirSync(root, { recursive: true })) {
    const file = join(root, String(rel));
    if (!/\.(ts|tsx|js|mjs|cjs)$/.test(file)) continue;
    const lines = readFileSync(file, "utf8").split("\n");
    lines.forEach((line, i) => {
      if (line.includes(ALLOW)) return;
      for (const [pattern, why] of RULES) {
        if (pattern.test(line)) {
          console.error(`${file}:${i + 1}: ${why}\n    ${line.trim()}`);
          failures++;
        }
      }
    });
  }
}

if (failures > 0) {
  console.error(`\n${failures} float-money finding(s).`);
  process.exit(1);
}
console.log("No float-money patterns found.");

Save the assistant's cart function from section 1 as examples/ai-cart.ts, then point the scanner at it:

node scripts/forbid-float-money.mjs examples

Given that file, the rules match three lines: line 2 (price: number), line 4 (taxRate: number) and line 7 (toFixed). The script should exit with code 1 and end with 3 float-money finding(s). Then run it on the real code:

npm run lint:money

Expected: No float-money patterns found. The scanner only covers src, which is why the float baseline lives in test. For a legitimate non-money use, such as Math.round on a pixel width, add a trailing comment like // money-float-ok: layout math. The reason then shows up in review.

This is a line-based heuristic, not a parser. It will miss const p: number and can flag a taxonomy: number. If you already use ESLint with typescript-eslint, move these rules into no-restricted-syntax selectors for better precision. The zero-dependency script is a starting point you can audit in a minute.

8. Prove the guards bite (do not skip this)

A passing suite proves nothing if it would also pass with the guards removed. Run three deliberate sabotage checks and revert each one afterwards:

  1. Widen the type. In src/money.ts, change parseMoney(input: string, …) to parseMoney(input: string | number, …). Run npm run typecheck. Expected: error TS2578 on the parseMoney(19.99, "USD") line in types.check.ts. (You may also see an error inside parseMoney, because DECIMAL.exec expects a string.)
  2. Remove the runtime check. Delete the typeof minor !== "bigint" block in fromMinor. Run npm test. Expected: the "rejects floats at runtime" test fails on the fromMinor(1999 as unknown as bigint, …) assertion.
  3. Silently round instead of rejecting. Replace the RangeError branch in parseMoney with fraction.slice(0, exponent) truncation. Expected: "rejects more precision than the currency allows" fails.

If any sabotage still passes, a guard is missing. Fix the test before trusting the suite. This is a hand-run form of mutation testing. A tool such as Stryker can automate it later.

9. Tell the assistant, then do not rely on it

Put the contract where your coding agent reads project instructions (for example AGENTS.md or CLAUDE.md in the repo root). This reduces how often bad code gets generated. The checks above decide whether it gets merged.

## Money

- All monetary values use `Money` from `src/money.ts`. Never use `number`,
  `parseFloat`, `toFixed` or `Math.round` for amounts, prices, taxes or totals.
- Create money with `parseMoney("19.99", "USD")` or `fromMinor(1999n, "USD")`.
- Rates and percentages are decimal strings: `multiply(m, "0.0825", "half-even")`.
  Always choose the rounding mode explicitly and mention it in the PR description.
- Split amounts with `allocate`, never by dividing and rounding.
- API and database boundaries carry amounts as strings or integer minor units.
- Do not edit `test/types.check.ts` or remove `@ts-expect-error` lines to make
  the build pass. A failing guard means the code is wrong, not the test.
- Before finishing: run `npm run check` and report its real output.

The last two rules target a known agent failure mode: an agent that hits a red check can "fix" the test instead of the code. Protect test/types.check.ts with a CODEOWNERS entry or a required review, so the agent can't remove the guard without a human seeing it.

10. Wire it into CI

A minimal GitHub Actions workflow, .github/workflows/money.yml:

name: money
on: [push, pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm run check

npm ci needs the package-lock.json that npm install created in section 3, so commit it. Make the job a required status check on the main branch. Otherwise the gate is advisory.

11. Boundaries where floats come back

Most float regressions don't enter through the money module. They enter at the edges:

12. Failure cases and how to read them

SymptomLikely causeWhat to do
TS2578: Unused '@ts-expect-error' directiveA signature was widened to accept number or made a parameter optionalRevert the widening; convert at the boundary instead
TypeError: Money amounts must be decimal strings, got numberA float reached parseMoney through any, JSON or JavaScriptTrace the caller and fix the source of the float, not the guard
RangeError: "1.005" has more than 2 fraction digitsAn upstream computation produced sub-cent precisionDo the computation with multiply and an explicit rounding mode
TypeError: Currency mismatchMixed currencies in one sumConvert explicitly with a rate and a rounding mode, then add
Node fails on .ts imports or reports unsupported syntaxNode older than 22.6, or enum/parameter properties in sourceUpgrade Node; keep erasableSyntaxOnly on so tsc catches the syntax first
Scanner flags a non-money lineLine-based heuristicAdd // money-float-ok: reason or rename the identifier

13. Limitations

14. Final checklist

  1. npm run typecheck passes, and fails with TS2578 when you widen a signature to number.
  2. npm test passes, and fails when you remove a typeof guard.
  3. node scripts/forbid-float-money.mjs examples flags the AI cart sample; npm run lint:money passes on src.
  4. Amounts cross API and database boundaries as strings or integer minor units.
  5. The agent instructions forbid editing guard tests, and CI runs npm run check as a required check.

Once this is in place, the answer to "Как не дать ИИ использовать float для денег" is no longer a prompt you hope the assistant follows. It's a red build the assistant can't argue with.

More practical walkthroughs: all guides · Terms used in this article: glossary.

We publish what works for us—and implement the same solutions for your business. We design AI automation, Telegram bots, chats, and AI agents for real-world processes. Discuss your project →