Every amount, quantity and moment in an Expo app is stored once, in one canonical integer form (minor units plus an ISO 4217 code, a canonical unit, epoch milliseconds), changed only by pure tested functions that never lose or invent a minor unit, and turned into text only at the edge, so a language, digits or unit-system change on iOS or Android never corrupts a stored value. Two zero-dependency checkers prove it on the app's own code.
Rules that must hold
- Money is
Money: an integer count of minor units plus an ISO 4217 code (money(minor, currency)fromsrc/core/money/money.ts), in types, state, rows and APIs; a plain number field is named<what>Minor. Never a float, a major-unit number or a formatted string. Why: binary floats cannot hold 0.01, and a total drifts in a way nobody can re-derive. (float-money,money-integer) - Scale only by the ISO 4217 exponent, from the generated table; an unknown code is a typed
failure. JPY 0, USD 2, KWD 3, CLF 4 decimals;
parseCurrencyCodereturnsunknown-currency. Never* 100,/ 100,toFixed(2)or?? 2, and never the exponent from Intl/CLDR, whose display digits differ for 16 codes (IQD 3 vs 0). Why: a hard-coded 100 is a 100× error in yen and 10× in dinars, and Intl formats an unknown code likeZZZwith two decimals instead of refusing it. (hard-coded-scale,exponent-default,exponent-table,unknown-currency) - One currency per aggregate; arithmetic only through the Money functions.
addMoney,subtractMoney,sumMoney,compareMoneythrow on mixed currencies and unsafe integers; changing currency isconvertMoneywith an explicit rate, rounded once. No arithmetic on.minoroutsidesrc/core/money/. Why: EUR plus USD is a bug, not data, and only the functions check it. (raw-minor-arithmetic,cross-currency,convert-money) - Every division of money goes through
allocate()(largest remainder, ties to the lower index): bill splits, prorated tax or discounts, instalments,splitEvenly. Never divide and round each part. Why: the parts then sum to the whole to the exact minor unit, for any input, every time. (money-division,allocate-vectors,allocate-conservation,allocate-refuses) - Rates are exact ratios applied once, at the total, with a named rounding mode.
parsePercent('8.25'),basisPoints(825),applyRate(amount, rate, 'half-even'),includedTaxfor tax-inclusive prices; thenallocate()the result. Neveramount * 0.0825, never round an intermediate sum. Why: the percent-to-minor-units step is the second rounding site that allocate tests never see. (money-float-rate,apply-rate) - Text and vendor values enter exactly, at the boundary. Input is normalized at the edge
(
normalizeDecimalInputwith the app's locale tag), thenparseMoneyreads it in the currency's exponent and refuses extra decimals (too-many-decimals), never rounding silently, and a group separator only where it can stand (ambiguous-separator: English1,000is asked about once, the price of never reading a 1000× amount); store prices enter throughmoneyFromStoreDouble(expo-iap'spricedouble, both platforms) ormoneyFromMicros(Android subscription micros), and the price shown to pay is the store's owndisplayPrice. Why:parseFloat('19.99')is already wrong, and Arabic-Indic digits throw in a naive parser. (parse-money,store-price) - Format only at the edge, in
src/i18n/:formatMoney(amount, localeTag)oruseFormatMoney(), with fraction digits set to the ISO exponent and the value passed as exact decimal text. Nostyle: 'currency'elsewhere, no currency symbol glued to a number, no formatting insrc/core/. Why: the forced FormatJS polyfill then prints the same text in Jest, on iOS and on Android, in the person's digits, and a float input is a cent off past 2^53 / 100. (currency-format-edge,currency-fraction-digits,currency-symbol-literal,core-formats) - Derive totals; store inputs. An order total, a balance or a share is computed on read from
its lines and rates; a stored figure is a fact of record (the amount actually charged) and is named
so (
chargedMinor). Entities link by stable id, never by embedded copies. Why: a stored total is stale the moment a line changes, and two sources drift. - A physical quantity is an integer in its dimension's canonical unit, named with the unit
(
durationMs,distanceMm,weightG,volumeMl,sizeBytes). Conversion uses exact factors (a mile is 1,609,344 mm) and rounds once, half-even, into the canonical unit; the unit system (expo-localizationmeasurementSystem, or a setting) only changes the display. Why: miles typed and kilometres typed must be the same row. (unit-suffix,units-exact) - An instant is UTC epoch milliseconds,
<what>AtMsin code and<what>_at_ms INTEGERin SQLite. ISO 8601 text exists only at the wire, always withZor an offset, parsed once byparseIsoInstant(offset-less text is refused) and written bytoIsoInstant; never aDateobject or a string in state or storage; seconds from an API convert once. A calendar day is a local DateKey (dates-streaks-and-statistics). Why: local text means a different moment on every phone, and seconds read as milliseconds land in 1970. (instant-type,instant-column,instant-parse) - Persist and send the canonical form. SQL money is
<name>_minor INTEGER NOT NULLin a STRICT table with the currency asTEXTon the aggregate; JSON money is decimal text with its code or an integer of minor units, never a JSON float; a schema field isv.pipe(v.number(), v.safeInteger()). Why: STRICT refuses a REAL in an INTEGER column, andAVG()returns REAL. (money-column,instant-column,float-money) - The value-object core is pure and proven.
src/core/{decimal,money,units,time}/import onlysrc/core(BigInt arithmetic, no React Native, Expo or Intl) and every module ships its test with the pinned vectors and properties. Why: one tested core makes every rule above a millisecond test, and the checker can run the app's own modules. (money-core,core-tests,module-load,missing-module)
Workflow
-
Read the reference for the task:
Task Read Money type, currencies, exponents, conversion, store prices, iOS/Android behaviour references/money-model.md Splitting, tax, tip, discounts, rounding modes, the two rounding sites references/allocation-and-rates.md Showing money or quantities, an amount input field, digits, RTL references/edge-formatting-and-input.md Distances, weights, durations, timestamps, API dates references/units-and-instants.md SQLite columns, JSON and API shapes, store state, test recipes references/storage-and-tests.md A checker failure, the allow marker, options, NOT APPLICABLE references/checkers.md -
Check the prerequisites.
src/core/result.ts(error-handling-typed-results; a synced copy is intemplates/src/core/), the i18n layer with the forced FormatJS polyfills andlocaleTagFor(i18n-strings-and-catalogs), and the render helpers (unit-and-component-tests) for the hook tests. -
Copy the templates to the paths their first lines name: always
templates/src/core/decimal/andtemplates/src/core/money/;templates/src/core/units/for physical quantities;templates/src/core/time/for instants;templates/src/i18n/(format-money,use-format-money,use-locale-tag,normalize-decimal-input,format-quantity). Each ships with its test. Trim the exponent table only by deleting rows; never edit an exponent. -
Model the domain with
Moneyfields, the currency on the aggregate, rates asRate, quantities and instants as unit-named integers, and derived results in their own types (examples/order-split.ts). -
Convert at every boundary, once: repository rows (examples/order-row.ts), migrations (examples/v0002-create-orders.ts; the migration itself follows persistence-and-migrations), API responses (examples/order-response.ts), store products (examples/product-price.ts), input fields (examples/use-amount-field.ts).
-
Split and rate with
applyRateonce at the total, thenallocate()by the subtotals; test the sum property (examples/order-split.test.ts). -
Show values from model hooks with
useFormatMoney(),useLocaleTag()andformatQuantity; components receive finished strings. -
Test first for any rule change, then
npx jest src/core/decimal src/core/money src/core/units src/core/time src/i18n --ci --selectProjects unitandnpm run typecheck. -
Run the checks from the repo root:
node ${CLAUDE_SKILL_DIR}/scripts/check-value-core.mjs .andnode ${CLAUDE_SKILL_DIR}/scripts/check-value-code.mjs .; fix everyFAILline (file, rule and fix are printed) and rerun until both printRESULT: PASS.
Definition of done
- No money field, column or schema holds a float or a major-unit number; every amount is
Moneyor<what>Minor, and everyvalue-objects-allowmarker carries its reason. - Scaling, splitting and rates go through the core (
parseMoney,toDecimalString,allocate,applyRate,convertMoney); no* 100,/ n,* 0.08or.minorarithmetic outside it. - Money is formatted only by
formatMoney/useFormatMoney()insrc/i18n/, with ISO digits. - Totals are derived; stored figures are facts of record and named so.
- Quantities and instants are unit-named integers; ISO text is parsed once with an offset.
- The core modules and their tests are the templates or pass the same tests;
npm run typecheckand the Jest run in workflow step 8 pass. -
node ${CLAUDE_SKILL_DIR}/scripts/check-value-core.mjs .printsRESULT: PASS(an app with no money, units or instant module printsNOT APPLICABLE: no value-object core: ...). -
node ${CLAUDE_SKILL_DIR}/scripts/check-value-code.mjs .printsRESULT: PASS.
Anti-patterns
price: numberholding 19.99. StoreMoney(orpriceMinor: numberholding 1999).(priceMinor / 100).toFixed(2)for display. Wrong for JPY and KWD, Latin digits only, no symbol placement.formatMoney(price, localeTag).EXPONENTS[code] ?? 2, or digits fromresolvedOptions().maximumFractionDigits. An unknown code is a failure; CLDR says IQD has 0 decimals where ISO 4217 says 3.Math.round(total / people)for each person. Three people and 10.00 give 3.33 × 3 = 9.99.splitEvenly(total, 3)gives 3.34, 3.33, 3.33.subtotal * 0.0825, or recomputing a printed tax from its rate. Keep the rate exact and apply it once; a tax the receipt states is the ground truth.- Summing separately rounded parts to get a total. Round the total once, then allocate it.
a.minor + b.minorin a feature. It skips the currency and overflow checks;addMoney(a, b).`$${amount}`or'€' + text. The symbol position, spacing and digits are the locale's.parseFloat(text)on a price field, orNumber('١٢٫٥'). Normalize, thenparseMoney.- Storing
totalnext to the lines it sums. Derive it; it is stale after the next edit. createdAt: Datein a store,created_at TEXTin SQLite,new Date('2026-10-10T08:30'). Epoch ms increatedAtMs; offset-less text is local time on an unknown phone.distance: numberwith no unit, or storing miles because the person chose miles. Canonical integer, unit in the name, convert for display.- Showing
formatMoney(productPrice)as the price to pay. Show the store'sdisplayPrice; keep the Money for arithmetic.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/money-model.md | Decisions and reasons, the Money API, the ISO 4217 table and its refresh, CLDR differences, safe ranges, BigInt on Hermes, conversion, store prices (expo-iap 5.8.0), iOS/Android table, pitfalls | Workflow step 1; any money type or currency change |
| references/allocation-and-rates.md | Largest remainder with verified vectors, the split pipeline, the two rounding sites, rounding modes, tax-inclusive prices, discounts and refunds, edge policies | Workflow steps 1 and 6 |
| references/edge-formatting-and-input.md | formatMoney facts verified under the polyfill, digits and RTL marks, display options, caching, amount inputs and keyboards, store display prices, unit formatting |
Workflow steps 1, 5 and 7 |
| references/units-and-instants.md | Canonical units and exact factors, choosing granularity, temperature, durations versus days, instants, ISO 8601 at the wire, seconds versus milliseconds, Date pitfalls | Workflow steps 1 and 4 |
| references/storage-and-tests.md | SQLite types and aggregates (verified), STRICT tables, JSON and API shapes, valibot, Zustand state, test recipes and property tests | Workflow steps 5 and 8 |
| references/checkers.md | Both checkers' rules, scopes and name lists, the allow marker, options, expected output, troubleshooting, multi-package | Workflow step 9, and when a check fails |
templates/src/core/decimal/ |
decimal.ts (exact decimal text), rounding.ts (divideRounded, rounding modes) and tests |
Workflow step 3, always |
templates/src/core/money/ |
currency-exponents.ts (generated ISO table), currency.ts, money.ts, allocate.ts, rate.ts, money-text.ts, store-price.ts and tests |
Workflow step 3, always |
templates/src/core/units/ |
units.ts (dimensions, exact factors, parse and display) and its test |
Workflow step 3, for quantities |
templates/src/core/time/ |
instant.ts (parseIsoInstant, toIsoInstant, epoch seconds) and its test |
Workflow step 3, for instants |
templates/src/core/result.ts |
The Result type (synced from error-handling-typed-results; do not edit here) | Workflow step 2, only when the app lacks it |
templates/src/core/result.test.ts |
Its test (synced; do not edit here) | With result.ts |
templates/src/i18n/ |
format-money.ts, use-format-money.ts, use-locale-tag.ts, normalize-decimal-input.ts, format-quantity.ts and tests |
Workflow step 3, with the i18n layer |
| examples/order-split.ts | An Order shared between Accounts: subtotals, tax and tip, all closed by allocate(), derived |
Workflow steps 4 and 6 |
| examples/order-split.test.ts | Its worked example and the sum property | Workflow step 6 |
| examples/order-row.ts | Repository rows to Money and back, with typed failures | Workflow step 5 |
| examples/v0002-create-orders.ts | A migration with _minor, _at_ms, _on and _bps columns, no stored total |
Workflow step 5 |
| examples/order-response.ts | An API response schema and its one conversion | Workflow step 5 |
| examples/product-price.ts | A store product's price to Money (Android subscription micros, else the double), with the allow marker | Workflow step 5 |
| examples/use-amount-field.ts | A money input's model hook: normalize, parse, message by failure kind | Workflow step 5 |
scripts/check-value-code.mjs |
Scans src/ for float money, hard-coded scales, raw arithmetic, formatting outside the edge, unit-less names, wrong SQL columns, a missing or untested core |
Workflow step 9 |
scripts/check-value-core.mjs |
Runs the app's money, units and instant modules against the rules (vectors, 400 seeded splits, ISO table) | Workflow step 9 |
scripts/lib/value-names.mjs |
The money, physical and instant name lists both checkers use | When a name is misjudged |
scripts/lib/sql-columns.mjs |
Reads column names and types from SQL in string literals | Never by hand |
scripts/lib/value-core-checks.mjs |
The currency, Money and allocation checks | When a core rule changes |
scripts/lib/value-edge-checks.mjs |
The rate, conversion, text, store-price, unit and instant checks | When an edge rule changes |
scripts/lib/app-modules.mjs |
Imports the app's pure TypeScript (synced from the library; do not edit here) | Never by hand |
scripts/lib/source-scan.mjs |
Shared source lexer and helpers (synced from the library; do not edit here) | Never by hand |
scripts/check-lib.mjs |
Shared checker library (synced from the library; do not edit here) | Never by hand |
scripts/selftest.mjs |
Proves both checkers pass the templates and examples and catch every planted bug | After changing a checker, a rule, a template or an example |
assets/iso-4217.json |
ISO 4217 exponents (list one, published 2026-09-17) and the 16 CLDR differences | When refreshing the currency table |
assets/shared.json |
The synced-file manifest | Never by hand |
tests/ |
bases/app/ and fixtures/ (overlay cases for both checkers) |
When a self-test case changes |
Related skills
error-handling-typed-results- the Result type and failure kinds the parsers return.i18n-strings-and-catalogs- locale tags, digits, the forced polyfills and catalog messages.dates-streaks-and-statistics- local calendar days, the clock and day arithmetic.persistence-and-migrations- tables, STRICT migrations and repositories.networking-and-server-state- the ApiClient and response validation at the wire.in-app-purchases- the purchase flow and the store's product prices.forms-and-input- text fields, keyboards and form validation around an amount field.state-management-zustand- stores that hold Money and derive totals in selectors.unit-and-component-tests- Jest setup, render helpers and property tests.