money
| Subpath | @noy-db/hub (root export) |
| Type | Core feature — activated per field by declaring a money() field descriptor. |
What it does
Storing prices, balances, or totals as a JavaScript number is a well-known trap: 0.1 + 0.2 doesn't equal 0.3, and floats lose precision entirely past Number.MAX_SAFE_INTEGER. The money() field descriptor closes that gap. It marks a schema field as currency-safe: values are quantized to a scaled integer (cents, for a 2-decimal currency) and stored as a digit string, so arithmetic never touches floating point and stays exact past 2^53. On read, the field decodes back to an exact decimal string ('123.45'), with optional locale-formatted and plain-number sibling fields for display.
It also ships two arithmetic helpers that solve the two places floating-point money math typically breaks: mulRate() (apply a rate — VAT, a fee percentage — with one controlled rounding step) and allocate() (split an amount across buckets so the parts always sum back to the original, to the cent).
money() is a sibling of the i18n service's dictKey()/i18nText() field descriptors: a schema-layer declaration, not a with*() strategy passed to createNoydb().
Setup
Import money and declare it in a collection's moneyFields map — the field's underlying schema type stays a plain number | string union (money accepts either on write):
import { createNoydb, money } from '@noy-db/hub'
import { toMemory } from '@noy-db/to-memory'
import { z } from 'zod'
const db = await createNoydb({
store: toMemory(),
user: 'alice',
secret: 'a-strong-secret',
})
interface Invoice extends Record<string, unknown> {
id: string
total: number | string
}
const vault = await db.openVault('books')
const invoices = vault.collection<Invoice>('invoices', {
schema: z.object({ id: z.string(), total: z.union([z.number(), z.string()]) }),
moneyFields: { total: money({ currency: 'EUR', scale: 2 }) },
})
Since 0.3.0-pre.9, the same declaration can be spelled
through the via field-feature composer — viaFields: { total: via(money({ currency: 'EUR', scale: 2 })) }. Both spellings compile to the same internal
binding, with byte-identical stored envelopes and identical describe() output; moneyFields
remains fully supported. Declaring the same field under both spellings throws ValidationError.
money(options) has two mutually exclusive modes:
- Fixed —
money({ currency: 'EUR' }): the field always holds one currency. The stored/read value is a bare amount. - Multi —
money({ currencies: ['EUR', 'USD'] })(orcurrencies: 'any'): the currency travels with the record. The value is{ amount, currency }on write; reads decode the same shape.
Usage
Write and read — exact decimals, locale formatting
Writing accepts a plain number or a decimal string; reading returns the canonical decimal string, plus <field>Formatted and <field>Number virtuals when a locale is passed to get():
await invoices.put('a', { id: 'a', total: 123.45 })
const read = await invoices.get('a', { locale: 'de-DE' }) as Record<string, unknown>
// → {
// id: 'a',
// total: '123.45', // exact decimal string
// totalFormatted: '123,45 €', // Intl.NumberFormat at the given locale
// totalNumber: 123.45, // convenience JS number (lossy past 2^53 — display only)
// }
Without a locale option, get()/list() still return the exact decimal string for total — only the Formatted/Number siblings are omitted.
mulRate() — apply a rate exactly
mulRate(amount, rate, opts?) multiplies a money amount by a decimal rate (e.g. a VAT rate) with a single controlled rounding step at the end, instead of the repeated float rounding that produces off-by-a-cent totals:
import { mulRate } from '@noy-db/hub'
mulRate('10000.00', 0.07) // → '700.00' (7% VAT)
mulRate('33.33', '0.07', { rounding: 'floor' }) // → '2.33'
allocate() — split with zero drift
allocate(amount, weights, opts?) splits an amount across weighted buckets using the largest-remainder method: the returned parts always sum back to the input exactly, with no leftover cent unaccounted for:
import { allocate } from '@noy-db/hub'
allocate('100.00', [1, 1, 1])
// → ['33.34', '33.33', '33.33'] (sums to '100.00')
allocate('0.05', [3, 7])
// → ['0.02', '0.03']
Querying and summing a money field
where() on a declared money field accepts operands in major units (100, '100.00') and compares exactly in scaled-integer space — no manual cents conversion needed:
const over100 = invoices.query().where('total', '>', 100).toArray()
// → [ { id: 'a', total: '123.45' } ] (the invoice put earlier, 123.45 > 100)
Summing, averaging, or taking the min/max of a money field is part of the separate aggregate service: pass withReduce() to createNoydb, then use moneySum/moneyMin/moneyMax (or plain sum/min/max, which are auto-rewritten to the exact BigInt reducer for any field declared in moneyFields) — see aggregate → exact money aggregation.
Indexing a money field for ==/in lookups
If you add the indexing service and declare a money field in indexes, an equality or membership query on that field skips the linear scan and resolves through the index. The fast path covers where(field, '==', …) and where(field, 'in', […]) on a fixed-mode field:
import { withIndexing } from '@noy-db/hub/indexing'
const db = await createNoydb({
store: toMemory(),
user: 'alice',
secret: 'a-strong-secret',
indexingStrategy: withIndexing(),
})
const vault = await db.openVault('books')
const invoices = vault.collection<Invoice>('invoices', {
schema: z.object({ id: z.string(), total: z.union([z.number(), z.string()]) }),
moneyFields: { total: money({ currency: 'EUR', scale: 2 }) },
indexes: ['total'],
})
await invoices.put('a', { id: 'a', total: 123.45 })
const exact = invoices.query().where('total', '==', 123.45).toArray()
// → [ { id: 'a', total: '123.45' } ] (index lookup, no scan)
The index is probed with the field's stored scaled-integer form, so an operand written in major units (123.45) matches the stored value without a floating-point round-trip. Only == and in take the fast path: between, >, <, and the other range operators — and any query on a multi-currency field — always fall back to a full scan, because a hash index has no single stored value to serve for those.
Mixed-era records still match. A record written before its field was declared money (or that otherwise skipped the money write path) can carry a non-canonical stored string — '0100' where a canonical write would store '100'. The eager-mode index normalizes every money bucket key as it writes it, so a legacy record lands in the same bucket a canonical write produces: an ==/in probe for the canonical amount finds it, and the indexed result matches a forced scan exactly. This holds when the index is first built, when a record is upserted with put(), and when one is removed with delete(), so updating or deleting a legacy record leaves its bucket correct.
Formatting a virtual computed field
A virtual computed field — one whose value is recomputed on every read and never stored — can be declared as money on the same field, and money formats its output exactly like a stored money field. Compose the two with via():
import { via, computed } from '@noy-db/hub'
interface Priced extends Record<string, unknown> { id: string; base: number }
const priced = vault.collection<Priced>('priced', {
viaFields: {
doubledPrice: via(
computed((r) => (r.base as number) * 2, { deps: ['base'], mode: 'virtual' }),
money({ currency: 'EUR', scale: 2 }),
),
},
})
await priced.put('a', { id: 'a', base: 10.5 })
const read = await priced.get('a', { locale: 'de-DE' }) as Record<string, unknown>
// → {
// id: 'a',
// doubledPrice: '21.00', // the fn's 21 quantized to the field's scale
// doubledPriceFormatted: '21,00 €', // dressed like any stored money field
// doubledPriceNumber: 21,
// }
Money takes the function's major-units result, quantizes it to the field's scale (applying any declared rounding), and adds the Formatted/Number virtuals under a non-'raw' locale. If the function returns more precision than scale allows and no rounding is declared, the value is left raw and untouched rather than throwing — read-time formatting must never break a read; declaring a rounding policy makes it quantize instead. This dressing is fixed-mode only: a virtual field's plain number has no { amount, currency } shape for multi-currency mode. A materialized computed field declared as money is already formatted the same way. If the field is redacted by a taint policy, its Formatted/Number companions are stripped along with it, never left exposing the pre-redaction value.
API
All exports below are available from the @noy-db/hub root.
money(options)
function money(options: MoneyOptionsFixed | MoneyOptionsMulti): MoneyDescriptor
Builds the field descriptor. Throws TypeError if both or neither of currency/currencies are given, and throws MoneyCurrencyError at construction if a currency has no resolvable scale (unlisted ISO-4217 code with no explicit scale/scaleOverrides).
| Option (fixed mode) | Type | Default | Purpose |
|---|---|---|---|
currency | string | — (required) | The one currency this field holds |
scale | number | ISO-4217 minor-unit count for currency | Override the decimal places; required for a currency code not in the built-in ISO-4217 table |
rounding | RoundingMode | none (excess precision throws) | Rounding applied when a written value carries more precision than scale |
| Option (multi mode) | Type | Default | Purpose |
|---|---|---|---|
currencies | 'any' | readonly string[] | — (required) | Allow-list of currencies, or 'any' for every ISO-4217-known code |
scaleOverrides | Record<string, number> | {} | Per-currency decimal-place overrides; required for a currency not in the built-in table |
rounding | RoundingMode | none (excess precision throws) | Same as fixed mode |
RoundingMode is one of 'half-up' | 'half-even' | 'half-down' | 'up' | 'down' | 'ceil' | 'floor'.
MoneyDescriptor (the return value) exposes mode ('fixed' | 'multi'), fixedCurrency (set in fixed mode), scaleFor(currency), allows(currency), and soleCurrency() (the one currency implied by the descriptor, if any).
isMoneyDescriptor(x)
function isMoneyDescriptor(x: unknown): x is MoneyDescriptor
Runtime predicate for detecting a value built by money().
mulRate(amount, rate, opts?)
function mulRate(amount: number | string, rate: number | string, opts?: MulRateOptions): string
| Option | Type | Default | Effect |
|---|---|---|---|
scale | number | the amount's own fractional digits | Output scale |
rounding | RoundingMode | 'half-up' | Rounding for the final re-scale |
allocate(amount, weights, opts?)
function allocate(amount: number | string, weights: ReadonlyArray<number | string>, opts?: AllocateOptions): string[]
| Option | Type | Default | Effect |
|---|---|---|---|
scale | number | the amount's own fractional digits | Working scale |
Weights must be non-negative decimals with a positive sum; throws MoneyUnsupportedError otherwise. Negative amounts allocate the magnitude and negate every part, so the parts still sum to the (negative) input exactly.
scaleForCurrency(code)
function scaleForCurrency(code: string): number | null
Looks up the ISO-4217 minor-unit count for a currency code ('EUR' → 2, 'JPY' → 0, 'BHD' → 3); returns null for a code outside the built-in table, which is what forces an explicit scale/scaleOverrides for exotic currencies.
asMoney(value) / isMoneyString(value) / moneyNumber(value)
function asMoney(value: string | number): MoneyString
function isMoneyString(value: unknown): value is MoneyString
function moneyNumber(value: MoneyString | string | number): number
MoneyString is a branded string type (zero runtime cost) marking a value that has been through the money decode path, so a validator or read site can distinguish "already-canonical decimal" from a plain user-typed string. asMoney() is a runtime-guarded cast for trust boundaries (deserialized JSON, route params) — it throws MoneyUnsupportedError on a non-finite/non-decimal input. moneyNumber() is the one sanctioned escape hatch to a plain JS number, for display math and chart axes — explicitly lossy past 2^53; never feed the result back into a stored amount, use mulRate/allocate instead.
Errors
All extend NoydbError and are exported from @noy-db/hub.
| Error | Thrown when |
|---|---|
MoneyPrecisionError | A written value carries more decimal precision than the field's scale allows and no rounding mode is configured |
MoneyCurrencyError | A currency is disallowed for the field, or has no resolvable scale (unlisted ISO-4217 code with no explicit scale) |
MoneyUnsupportedError | An unsupported operation is attempted on a money field or value — e.g. a non-decimal amount/weight passed to mulRate/allocate, an empty allocate weight list, or avg() over a declared money field (see Limits below) |
Limits & gotchas
avg()is unsupported on money fields — it throwsMoneyUnsupportedErrorrather than silently returning a lossy figure. UsemoneySum()andcount()from the aggregate service and divide at the boundary.currencyandcurrenciesare mutually exclusive — passing both, or neither, throws aTypeErroratmoney()construction.- Unlisted currencies need an explicit scale —
scaleForCurrency()only covers the common ISO-4217 set; a code outside it (or a non-standard unit like a precious-metal ticker) must passscale(fixed mode) orscaleOverrides(multi mode), ormoney()throwsMoneyCurrencyErrorat construction. mulRate/allocateare pure functions — they take and return plain decimal strings/numbers with no vault or descriptor dependency; they don't read or write a field, and the working scale is inferred from the amount's own fractional digits unlessopts.scaleis given.- Cross-currency comparisons are undefined — in multi-currency mode, a
where()operand carries one currency; a record in a different currency matches!=and nothing else (no implied ordering across currencies). moneyNumber()/<field>Numberare display-only — both are explicitly lossy pastNumber.MAX_SAFE_INTEGER; never write the result back into a stored money field.- Lazy-mode money queries are Via-aware (since 0.3.0-pre.12) — a lazy-mode collection (
prefetch: false) keeps its own durable side-car indexes, and these now canonicalize each money bucket key exactly as the eager index does; the lazy==/inpost-filter also runs Via-aware against the raw stored record, so an indexedlazyQuery()no longer diverges from a forced scan at anyscale. Two narrower limits remain: lazyorderByon a money field doesn't yet sort in scaled space — equality/infiltering is correct, but ordering parity with eager is still pending; and a legacy record written before the field'sindexes: [...]declaration has no side-car until backfilled, so it needsreconcileOnOpen: 'auto'(or an explicitreconcileIndex()/rebuildIndexes()call) to become visible to the fast path.
See also
- via — the field-feature composer
money()plugs into - aggregate —
moneySum/moneyMin/moneyMaxand exactBigIntsummation over declared money fields - i18n — the sibling field-descriptor pattern (
dictKey()/i18nText()) money follows - computed — derived fields, which compose with money-typed fields
- Services overview · Orientation map