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

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:
- Most decimal cents have no exact representation.
0.1 + 0.2evaluates to0.30000000000000004. Adding0.1ten times gives0.9999999999999999, not1. - Rounding at the end hides the error until it doesn't.
Math.round(1.005 * 100) / 100returns1, because1.005 * 100is100.49999999999999. A "half-cent rounds up" rule silently rounds down. - Intermediate values leak. Once a float subtotal is compared (
paid >= subtotal), stored, or sent to another service, the error is no longer cosmetic. A customer who paid exactly 1.00 can fail a check against 0.9999999999999999 in the other direction, or an invoice line and a ledger line can differ by one cent.
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:
- Store minor units as
bigint. 19.99 USD is stored as1999ncents. Integers are exact, andbiginthas no 253 ceiling.bigintandnumberalso can't be mixed in arithmetic (1n + 1throwsTypeError), which works in our favour. - Accept amounts only as decimal strings or
bigint. Anumberargument is a type error at compile time and aTypeErrorat runtime. - 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.
- Make the branded type impossible to forge. A plain
{ minor: 1999n, currency: "USD" }object doesn't type-check asMoney. Values come only from the constructors. - 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.
- Allocation never loses a cent. Splitting 100.00 three ways gives 33.34 + 33.33 + 33.33.
- JSON carries strings.
{"amount":"19.99","currency":"USD"}, never19.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:
- Each guard sits on the boundary that a
numbercould cross.fromMinor,parseMoney,parseRate,multiplyQty,allocateandfromJSONeach checktypeof. Inside the module everything is alreadybigint. - The regex accepts no exponent notation, no
+, no spaces and no thousands separators. Without thetypeofcheck,String(1e21)would come through as"1e+21". With the regex, it's rejected anyway. - The rounding mode has no default. That's deliberate. An assistant that has to pick
"half-even"or"half-up"leaves a visible decision in the diff for a reviewer. - The allocation loop ends within one pass over the non-zero weights. Truncation leaves each share less than one minor unit short, so the remainder is smaller than the number of non-zero weights.
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:
- Widen the type. In
src/money.ts, changeparseMoney(input: string, …)toparseMoney(input: string | number, …). Runnpm run typecheck. Expected: error TS2578 on theparseMoney(19.99, "USD")line intypes.check.ts. (You may also see an error insideparseMoney, becauseDECIMAL.execexpects a string.) - Remove the runtime check. Delete the
typeof minor !== "bigint"block infromMinor. Runnpm test. Expected: the "rejects floats at runtime" test fails on thefromMinor(1999 as unknown as bigint, …)assertion. - Silently round instead of rejecting. Replace the
RangeErrorbranch inparseMoneywithfraction.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:
- Incoming JSON. If a partner API sends
"amount": 19.99, the float exists the momentJSON.parseruns.fromJSONrejects it. If you can't change the sender, read the raw body with a parser that keeps numbers as strings, or ask the provider for integer minor units. Don't convert withString(19.99)and hope. - Databases. Store integer minor units in an integer column, or use an exact
NUMERIC/DECIMALcolumn. Never useREAL/FLOAT/DOUBLE. Many Node drivers, including node-postgres by default, returnNUMERICas a string. Keep it that way and feed it toparseMoney. Don't install a type parser that turns it into anumber. - Forms.
<input type="number">andvalueAsNumbergive you a float. Read.value(a string), normalise the locale's decimal separator, and pass it toparseMoney. Show the user a validation error instead of guessing. - Serialisation of bigint.
JSON.stringifythrows onbigint. That's useful, because it forces you throughtoJSON. Don't "fix" it with a globalBigInt.prototype.toJSONthat returns aNumber. - Analytics and charts. Converting to
numberfor display or aggregation in a dashboard can be acceptable. Do it in a clearly named function outsidesrcmoney paths, and never feed the result back into accounting.
12. Failure cases and how to read them
| Symptom | Likely cause | What to do |
|---|---|---|
TS2578: Unused '@ts-expect-error' directive | A signature was widened to accept number or made a parameter optional | Revert the widening; convert at the boundary instead |
TypeError: Money amounts must be decimal strings, got number | A float reached parseMoney through any, JSON or JavaScript | Trace the caller and fix the source of the float, not the guard |
RangeError: "1.005" has more than 2 fraction digits | An upstream computation produced sub-cent precision | Do the computation with multiply and an explicit rounding mode |
TypeError: Currency mismatch | Mixed currencies in one sum | Convert explicitly with a rate and a rounding mode, then add |
Node fails on .ts imports or reports unsupported syntax | Node older than 22.6, or enum/parameter properties in source | Upgrade Node; keep erasableSyntaxOnly on so tsc catches the syntax first |
| Scanner flags a non-money line | Line-based heuristic | Add // money-float-ok: reason or rename the identifier |
13. Limitations
- Compile-time checks stop at
any.// @ts-ignore,as anyand plain JavaScript bypass them. That's why the runtime guards exist. Consider the typescript-eslint rules that restrictanyand ts-comments. - The currency table is a sample. Five currencies are hard-coded. A production system needs a maintained ISO 4217 table and a decision about currencies whose minor unit differs from the cash-rounding rules in practice.
- Strictness can annoy.
"1.500"for USD is rejected even though the value is exact. You can relax this to "strip trailing zeros", but do it deliberately. - No exchange rates, interest or tax law.
multiplyapplies one rate with one rounding step. Real tax rules may round per line, per invoice, or per jurisdiction. Encode that rule in your domain code, using this type. biginthas a cost. It's slower thannumberarithmetic. For typical business workloads that rarely matters. If you process very large batches, measure before you optimise, and keep any optimisation exact (for example, integernumberminor units withNumber.isSafeIntegerchecks).- The static scanner is a heuristic. Treat it as a tripwire for common assistant output, not a proof.
14. Final checklist
npm run typecheckpasses, and fails with TS2578 when you widen a signature tonumber.npm testpasses, and fails when you remove atypeofguard.node scripts/forbid-float-money.mjs examplesflags the AI cart sample;npm run lint:moneypasses onsrc.- Amounts cross API and database boundaries as strings or integer minor units.
- The agent instructions forbid editing guard tests, and CI runs
npm run checkas 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.