idr-formatting
v1.0.6
Published
Tiny helpers to format and parse Indonesian-style prices (IDR) with '.' thousands and ',' decimals.
Maintainers
Readme
idr-formatting
Tiny helpers to format and parse Indonesian-style prices (IDR).
- Thousands separator:
. - Decimal separator:
, - Minus sign is preserved (e.g.
-1050.5 → -1.050,5) - Optional exact parsing with
BigInt(no floating-point error)
Live demo
👉 https://netng.github.io/idr-formatting-example/
Features
formatIdr(value, options?)
- Smart dot/comma heuristics
- Preserves user decimals by default
- Optional
decimals(force fixed digits) &padZeros - Accepts numbers, strings, or exact
FixedIdrobjects
parseIdr(value, options?)
- Returns a JS number by default
- Or returns an exact FixedIdr (sign + units + scale) with
{ mode: "fixed" } - Ignores stray characters like
Rpor spaces
Works in browsers & Node (ESM & CJS)
Great fit for Rails (Importmap + Stimulus)
Install
npm install idr-formatting
# or
yarn add idr-formatting
# or
pnpm add idr-formattingUsage
ESM / modern bundlers (Vite, webpack, esbuild, Node ESM)
import { formatIdr, parseIdr } from "idr-formatting"
formatIdr("1000") // "1.000"
formatIdr("1050") // "1.050"
formatIdr("1050,32") // "1.050,32"
formatIdr(1050.32) // "1.050,32"
parseIdr("1.050,32") // 1050.32
parseIdr("1.000") // 1000
parseIdr("Rp 1.234,56") // 1234.56
parseIdr(" 10.000 ") // 10000CommonJS (Node with require)
const { formatIdr, parseIdr } = require("idr-formatting")
formatIdr(1000000) // "1.000.000"
parseIdr("1.234,56") // 1234.56Options
formatIdr(value, options?)
type FormatIdrOptions = {
/**
* "auto" (default): preserve decimals as typed (no rounding)
* number: force fixed decimals (round/pad as needed)
*/
decimals?: "auto" | number
/**
* If decimals === "auto", pad to at least 2 digits when decimal exists.
* E.g. "1050,5" -> "1.050,50"
*/
padZeros?: boolean
}Examples:
formatIdr("1050,5") // "1.050,5"
formatIdr("1050,5", { decimals: "auto", padZeros: true }) // "1.050,50"
formatIdr("1050,5678", { decimals: 2 }) // "1.050,57"
formatIdr("1000", { decimals: 2 }) // "1.000,00"
formatIdr(1000, { decimals: 0 }) // "1.000"parseIdr(value, options?)
type ParseIdrOptions = {
/** "number" (default) -> number|null; "fixed" -> FixedIdr|null (exact) */
mode?: "number" | "fixed"
}Exact parsing (no floating errors):
const fx = parseIdr("1.234.567,89", { mode: "fixed" })
// => { sign: 1, units: 123456789n, scale: 2, toNumber(), toString() }
fx.toString() // "1234567.89" (exact)
formatIdr(fx) // "1.234.567,89"| Input (raw) | formatIdr output | parseIdr output |
|--------------------------------|---------------------------|-------------------------------------|
| "1000" | "1.000" | 1000 |
| "1050" | "1.050" | 1050 |
| "1050,32" | "1.050,32" | 1050.32 |
| 1050.32 | "1.050,32" | 1050.32 |
| "1.500" | "1.500" | 1500 |
| "1.500,55" | "1.500,55" | 1500.55 |
| "12.34" | "12,34" | 12.34 |
| "-1050.5" | "-1.050,5" | -1050.5 |
| "Rp 1.234,56" | "1.234,56" | 1234.56 |
| " 10.000 " | "10.000" | 10000 |
| "" | "" | null |
| "abc" | "" | null |
| "9.223.372.036.854.775.807" | "9.223.372.036.854.775.807" | 9223372036854776000 (approx, due to JS Number limits) |
| "9.223.372.036.854.775.807,99" with { mode: "fixed" } | "9.223.372.036.854.775.807,99" | { sign: 1, units: 922337203685477580799n, scale: 2 } (exact) |
Large number with FixedIdr (exact parsing)
import { parseIdr, formatIdr } from "idr-formatting"
// Normal parse (as Number, precision lost for huge values)
parseIdr("9.223.372.036.854.775.807,99")
// => 9223372036854776000 (approx)
// Exact parse with BigInt-backed FixedIdr
const fx = parseIdr("9.223.372.036.854.775.807,99", { mode: "fixed" })
/*
fx = {
sign: 1,
units: 922337203685477580799n,
scale: 2,
toNumber: [Function],
toString: [Function]
}
*/
fx.toString() // "9223372036854775807.99" (exact decimal string)
formatIdr(fx) // "9.223.372.036.854.775.807,99"This way users see both:
- Regular
Numbermode (fast, but limited by JS precision). - Fixed mode (BigInt exact, good for finance & huge numbers).
Round-trip guarantee
For typical inputs, formatting and parsing are stable:
parseIdr(formatIdr("1050,32")) // 1050.32
formatIdr(parseIdr("1.050,32")) // "1.050,32"
parseIdr(formatIdr(-1500)) // -1500
formatIdr(parseIdr("-1.500")) // "-1.500"parseIdr(formatIdr(x)) === xfor most integers and decimals.formatIdr(parseIdr(x)) === xfor valid Indonesian-formatted strings.Trailing decimals are preserved as typed (unless you set
decimals: <number>).Non-formatting characters (
Rp, spaces) are ignored by parseIdr.
Limitations
Decimal length preserved by default (no auto-rounding):
formatIdr("1234,5678") // "1.234,5678".No auto currency symbol — add
Rpin your UI.Numbers have IEEE-754 limits. For huge/precise values use
{ mode: "fixed" }to getFixedIdr(BigInt-backed).Misplaced minus like
"1.000-"is not recognized; only leading - is preserved.
Rails (Importmap) + Stimulus
1) Pin via CDN
bin/importmap pin [email protected] --from jsdelivr
# or
bin/importmap pin [email protected] --from unpkg2) Stimulus controller
// app/javascript/controllers/idr_price_controller.js
import { Controller } from "@hotwired/stimulus"
import { formatIdr, parseIdr } from "idr-formatting"
export default class extends Controller {
static values = { submitRawTo: String }
connect() {
this.input = this.element
this.#ensureHidden()
this.format()
}
format() {
const cleaned = this.input.value.replace(/[^0-9,]/g, "")
this.input.value = formatIdr(cleaned)
const parsed = parseIdr(this.input.value) // Number | null
if (this.hidden) this.hidden.value = parsed ?? ""
this.input.setSelectionRange(this.input.value.length, this.input.value.length)
}
#ensureHidden() {
if (!this.hasSubmitRawToValue) return
if (!this.hidden) {
const hidden = document.createElement("input")
hidden.type = "hidden"
hidden.name = this.submitRawToValue
this.input.insertAdjacentElement("afterend", hidden)
this.hidden = hidden
}
}
}3) ERB snippet
<input
type="text"
name="product[price_display]"
data-controller="idr-price"
data-idr-price-submit-raw-to-value="product[price]"
data-action="input->idr-price#format blur->idr-price#format"
/>- User sees:
1.050,32 - Server receives (hidden):
1050.32
Tip: put Rp outside the input (addon/label) so caret position isn’t affected.
Test & Coverage
# run tests + coverage
npm run test
# watch mode
npm run test:watchContributing
- Fork & clone
- npm i
- npm run test
- PRs welcome!
License
MIT
