stablesince 0.2.0-pre.12

money

The `money()` field descriptor: exact-decimal currency storage with no floating-point drift, plus allocate()/mulRate() helpers that always add back up.
Subpath@noy-db/hub (root export)
TypeCore 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'] }) (or currencies: '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)TypeDefaultPurpose
currencystring— (required)The one currency this field holds
scalenumberISO-4217 minor-unit count for currencyOverride the decimal places; required for a currency code not in the built-in ISO-4217 table
roundingRoundingModenone (excess precision throws)Rounding applied when a written value carries more precision than scale
Option (multi mode)TypeDefaultPurpose
currencies'any' | readonly string[]— (required)Allow-list of currencies, or 'any' for every ISO-4217-known code
scaleOverridesRecord<string, number>{}Per-currency decimal-place overrides; required for a currency not in the built-in table
roundingRoundingModenone (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
OptionTypeDefaultEffect
scalenumberthe amount's own fractional digitsOutput scale
roundingRoundingMode'half-up'Rounding for the final re-scale

allocate(amount, weights, opts?)

function allocate(amount: number | string, weights: ReadonlyArray<number | string>, opts?: AllocateOptions): string[]
OptionTypeDefaultEffect
scalenumberthe amount's own fractional digitsWorking 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.

ErrorThrown when
MoneyPrecisionErrorA written value carries more decimal precision than the field's scale allows and no rounding mode is configured
MoneyCurrencyErrorA currency is disallowed for the field, or has no resolvable scale (unlisted ISO-4217 code with no explicit scale)
MoneyUnsupportedErrorAn 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 throws MoneyUnsupportedError rather than silently returning a lossy figure. Use moneySum() and count() from the aggregate service and divide at the boundary.
  • currency and currencies are mutually exclusive — passing both, or neither, throws a TypeError at money() 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 pass scale (fixed mode) or scaleOverrides (multi mode), or money() throws MoneyCurrencyError at construction.
  • mulRate/allocate are 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 unless opts.scale is 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>Number are display-only — both are explicitly lossy past Number.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 ==/in post-filter also runs Via-aware against the raw stored record, so an indexed lazyQuery() no longer diverges from a forced scan at any scale. Two narrower limits remain: lazy orderBy on a money field doesn't yet sort in scaled space — equality/in filtering is correct, but ordering parity with eager is still pending; and a legacy record written before the field's indexes: [...] declaration has no side-car until backfilled, so it needs reconcileOnOpen: 'auto' (or an explicit reconcileIndex()/rebuildIndexes() call) to become visible to the fast path.

See also

  • via — the field-feature composer money() plugs into
  • aggregate — moneySum/moneyMin/moneyMax and exact BigInt summation 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