Two data-loss bugs show up months after an API ships. An order id over 2^53 arrives rounded, so a client fetches the wrong receipt. A ledger total of 19.99 becomes 19.989999999999998 after one round trip, and a reconciliation job flags every transaction. Both come from the same mistake: treating JSON number as if it carried arbitrary integer or decimal precision. It does not.
What JSON numbers actually are
JSON itself has no integer type. Every number is a sequence of digits, but nearly every parser converts it to an IEEE 754 double-precision float, which has 53 bits of mantissa. That gives exact integers only up to Number.MAX_SAFE_INTEGER, which is 9,007,199,254,740,991 (about 16 decimal digits).
| Value |
type / format
|
Wire form | Risk |
|---|---|---|---|
| Small integer (count, age, qty) |
integer / int32
|
42 |
Safe |
| 64-bit id, snowflake, timestamp-key |
integer / int64
|
8421937123456789012 |
Loses precision in JS clients |
| Money, tax, exchange rates |
number (any) |
19.99 |
Binary float rounding |
| Exact decimal the client must not round | string |
"19.99" |
Safe, needs a parser |
| Arbitrarily large integer | string |
"987654321098765432109" |
Safe |
format: int64 is an annotation, not a guarantee. It tells a generator in a 64-bit language to use long or Int64, but JavaScript and TypeScript still decode the JSON number into a number and silently round it. If any consumer is a browser, a Node process, or a language without a native 64-bit safe integer, an int64 on the wire is unsafe regardless of what your server language does.
Large identifiers belong in strings
Snowflake ids, database sequences over 16 digits, blockchain values, and file sizes that can exceed 9 petabytes should be modeled as strings with a pattern and a description that says why:
OrderId:
type: string
pattern: '^[0-9]{1,20}$'
description: >-
64-bit order identifier serialized as a string to preserve precision in
JavaScript clients. Treat as an opaque numeric id; do not parse as a float.
example: "8421937123456789012"
A TypeScript generator emits orderId: string, so the digits survive. If you also offer the raw numeric form for systems that need it, expose it under a separate field and document which one clients must use for identity. Never ask a browser client to compare two int64 numbers parsed as floats; equality breaks at the same boundary.
If you control every consumer and they all use a parser with bigint support (for example a server-to-server API whose clients use JSON.parse(text, reviver) or a codegen runtime with a bigint option), type: integer, format: int64 is acceptable. State that requirement in the description; it is not the default for a public API.
Money is not type: number
Binary floating point cannot represent one tenth exactly, so 0.1 + 0.2 is not 0.3. Money represented as type: number invites rounding into every client, every mock, and every AI-generated test. There are three defensible patterns; pick one and use it everywhere.
Minor units as an integer. The amount in the smallest currency unit, with the currency on a sibling field:
Money:
type: object
required: [amount_minor, currency]
additionalProperties: false
properties:
amount_minor:
type: integer
format: int64
description: Amount in minor units (cents for USD and EUR, whole yen for JPY).
example: 1999
currency:
type: string
enum: [USD, EUR, JPY]
description: ISO 4217 code. Check the currency's decimal exponent; JPY has zero.
This is exact, easy to add in integers, and unambiguous as long as you document zero-decimal currencies. Do not assume every currency has two decimals; JPY and KRW have zero, and a handful of currencies use three.
Exact decimal as a string. Required when you must carry sub-minor precision, variable scale, or crypto amounts:
DecimalAmount:
type: string
pattern: '^-?[0-9]+(\\.[0-9]+)?$'
description: Exact decimal amount as a string; parse with a decimal library, never a float.
example: "19.9900"
A fixed-precision object with explicit scale, common in ledgers and payment networks:
amount: { type: integer, example: 19990 }
scale: { type: integer, enum: [0, 2, 3], example: 3 }
Whichever you choose, the rules that save you are: never mix patterns in one API, always send the currency or scale next to the amount, and never let a client infer decimals from the currency without a documented table.
Percentages, rates, and totals
The same trap applies to non-money decimals. Tax rates, exchange rates, discounts, and utilization ratios need a documented scale. A 7.5 percent tax sent as 0.075 is exact-looking but still a float; send a fixed basis-points integer (750 basis points) or a decimal string when exactness matters. For totals, document the rounding rule (half-up, half-even, round per line item or round the sum), because two correct clients can otherwise produce different cents.
What codegen, validators, and mocks do
-
integer/int32generates a plain number safely.integer/int64generatesnumberin TypeScript (unsafe above 2^53),longin Java,intin Go; only runtimes with a bigint decoder preserve it. -
stringids generatestring, which is always safe but shifts parsing to callers; the pattern keeps them honest. -
numbermoney generates a float everywhere and gives validators nothing to check about scale; a decimal string with a pattern lets validation reject malformed amounts. - A spec-driven mock should generate realistic minor-unit integers and quoted decimal strings from your examples, not random floats like
12.340000000000001. When an AI agent or mock produces a float money value against a decimal-string schema, that is a signal the schema was ignored, which is exactly what a contract check should catch.
Checklist
- Treat every JSON number as a double; exact integers are safe only up to 2^53.
- Serialize ids and integers above 16 digits as strings with a numeric pattern, unless every client provably decodes bigint.
- Do not model money as
type: number; choose minor-unit integers, decimal strings, or an amount-plus-scale object and use it consistently. - Always send the ISO 4217 currency next to an amount and document zero- and three-decimal currencies.
- Document the scale and rounding rule for rates, tax, and totals.
- Generate the client and confirm large ids stay strings and money never becomes a float.
- Have mocks and contract tests assert amounts round-trip exactly, including a value that exposes float error.
Get these right and the digits that matter, identity and money, survive every client and every retry unchanged.
You can define reusable Money and OrderId components, generate clients that preserve precision, and assert exact round-trips against a mock in one local-first workspace, right in your browser. For a DRY component library these money types belong in, see reusable JSON Schema components in OpenAPI.
Top comments (0)