Currency conversion in JavaScript takes two separate steps: calculate an amount with an exchange rate, then format that amount for the reader. Intl.NumberFormat handles the second step. Changing currency: "USD" to currency: "EUR" changes the label, not the value. For the rate layer, get a free SiftingIO API key and explore the Forex API; the example below runs locally with clearly marked synthetic rates.
This distinction matters in pricing pages, expense dashboards and portfolio interfaces. A perfectly formatted currency symbol can hide a completely wrong conversion. Here is a small implementation that keeps rate direction, rounding and presentation separate.
Formatting 100 dollars as euros does not convert it#
Run this in a modern JavaScript runtime:
const label = new Intl.NumberFormat("en-IE", {
style: "currency",
currency: "EUR"
}).format("100.00");
console.log(label); // A label for 100 euros, not a USD-to-EUR conversion.
There is no source currency or exchange rate in that call. The formatter cannot know whether the input represents dollars, euros or something else. It assumes the number is already denominated in the requested currency. This is a presentation API, as described in the Intl.NumberFormat reference.
Keep three values distinct in your application:
| Value | Example | What it means |
|---|---|---|
| Original amount | 100.00 USD | The amount before conversion |
| Converted amount | 92.59 EUR | The result after applying the chosen rate and rounding policy |
| Display label | €92.59 | A localized representation of the converted amount |
The numbers here are illustrations, not current quotes. Never replace the original amount in storage with the formatted label.
Choose the rate direction before doing the math#
Suppose a synthetic EURUSD reference rate is 1.0800 USD per EUR. Converting EUR to USD means multiplying. Converting USD to EUR means dividing by that same rate.
| Input | Synthetic rate | Calculation | Rounded result |
|---|---|---|---|
| 100 EUR | EURUSD = 1.0800 | 100 × 1.0800 | 108.00 USD |
| 100 USD | EURUSD = 1.0800 | 100 ÷ 1.0800 | 92.59 EUR |
| 100 USD | USDJPY = 150.125 | 100 × 150.125 | 15,013 JPY |
The last row uses zero decimal places and rounds an exact half upward. It is a policy chosen for this example, not a universal payment-processing rule.
Treat a rate as a value with units, not a floating-point number you can multiply in either direction. Keep the base and quote currencies alongside it. If your input amount is already in the target currency, no FX lookup is needed.
A reference conversion is also not a promised bank or broker fill. Spreads, fees and payment-provider rules can change the amount actually charged or received. This example does not model those costs or choose an executable bid or ask.
Convert decimal strings without an intermediate floating-point amount#
For this small example, decimal strings are parsed into integer fractions. BigInt arithmetic keeps multiplication and division exact until the final rounding step. Inverting the rate uses the original fraction; it does not first round a reciprocal such as 1 / 1.08.
The helper accepts non-negative amounts and positive rates, with up to 12 digits before the decimal point and eight after it. It deliberately rejects commas, currency signs, exponent notation and negative values. Normalize user input separately rather than guessing what 1,234 means.
function decimalRatio(value) {
if (typeof value !== "string" ||
!/^\d{1,12}(?:\.\d{1,8})?$/.test(value)) {
throw new TypeError("Expected a bounded unsigned decimal string");
}
const [whole, fraction = ""] = value.split(".");
return [BigInt(whole + fraction), 10n ** BigInt(fraction.length)];
}
function convertAmount(amount, rate, { inverse = false, digits = 2 } = {}) {
if (typeof inverse !== "boolean" || ![0, 2, 3].includes(digits)) {
throw new RangeError("Use a boolean direction and 0, 2 or 3 digits");
}
const [a, aScale] = decimalRatio(amount);
const [r, rScale] = decimalRatio(rate);
if (r === 0n) throw new RangeError("Rate must be positive");
const numerator = a * (inverse ? rScale : r) * 10n ** BigInt(digits);
const denominator = aScale * (inverse ? r : rScale);
// Round a non-negative rational value to the nearest integer; ties go up.
const minor = (2n * numerator + denominator) / (2n * denominator);
const text = minor.toString().padStart(digits + 1, "0");
return digits === 0
? text
: `${text.slice(0, -digits)}.${text.slice(-digits)}`;
}
const eur = convertAmount("100.00", "1.0800", { inverse: true });
const jpy = convertAmount("100.00", "150.125", { digits: 0 });
console.log(eur); // "92.59"
console.log(jpy); // "15013"
The return value is a decimal string in major currency units. "15013" means 15,013 yen, not 150.13 yen. The helper does not infer currency codes or validate a pair: the caller must supply the correct direction and output precision.
For a larger application, an established decimal library may fit better. The important boundary is the same: preserve decimal inputs before arithmetic. Converting a number to a string after it has already lost precision cannot recover the original value. See parsing JSON numbers as decimals for that separate input problem.
Format the converted amount, not the rate#
Use the same precision policy in conversion and formatting. The small map below covers only this example's currencies; it is not a complete currency registry or payment-provider specification.
const displayDigits = new Map([
["USD", 2], ["EUR", 2], ["GBP", 2], ["JPY", 0]
]);
function formatAmount(amount, currency, locale) {
const digits = displayDigits.get(currency);
if (digits === undefined) throw new RangeError("Unsupported currency");
if (typeof amount !== "string" ||
!/^\d{1,24}(?:\.\d{1,3})?$/.test(amount) ||
(amount.split(".")[1] || "").length > digits) {
throw new TypeError("Expected an already-rounded decimal amount");
}
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits: digits,
maximumFractionDigits: digits
}).format(amount);
}
console.log(formatAmount(eur, "EUR", "en-IE"));
console.log(formatAmount(eur, "EUR", "de-DE"));
console.log(formatAmount(jpy, "JPY", "ja-JP"));
Both euro labels represent 92.59 EUR; the locale changes their punctuation and symbol placement. It does not select a currency or fetch a rate.
Modern Intl.NumberFormat.format() implementations accept decimal strings without first reducing them to a JavaScript Number. Older implementations can behave differently. Check your supported runtimes, and avoid tests that require an exact space character or currency-symbol layout: localization output can vary. These behaviors are documented in the format() reference.
The formatter rejects extra decimal places instead of silently rounding the amount a second time. Set tighter amount limits if your application needs them.
Test the cases a happy-path demo misses#
Test the numeric string independently from its display label. These expected values follow the explicit half-up policy above:
| Test | Call or condition | Expected |
|---|---|---|
| Direct conversion | convertAmount("100", "1.08") | "108.00" |
| Inverse conversion | convertAmount("100", "1.08", {inverse: true}) | "92.59" |
| Exact half | convertAmount("1", "1.005") | "1.01" |
| Below a half | convertAmount("1", "1.0049") | "1.00" |
| Zero amount | convertAmount("0", "1.08") | "0.00" |
| Invalid rate | Rate is "0", "NaN" or "-1" | Throw an error |
| Ambiguous text | Amount is "1,000" or "€10" | Throw an error |
Also test your rounding boundary. Three converted line items of 0.335 each become 0.34 each when rounded separately, totaling 1.02. Adding the unrounded values first gives 1.005, which rounds to 1.01. Neither approach is interchangeable with the other. Decide where rounding belongs in your application and keep the rule consistent; this tutorial is not an accounting or billing specification.
Connect the calculation to a rate source#
Put the API call on your server. A browser bundle is not a place to hide a provider key. Our web-app API key guide covers that boundary.
For a SiftingIO integration, start with the Forex product and API documentation. Adapt the documented response into your own amount, rate and currency fields. None of the helper names or inputs above is a SiftingIO endpoint or response schema.
Alongside each conversion, retain the input amount, currency pair, original rate, source timestamp, retrieval time and rounding policy. Give the client an explicit unavailable or stale state when the rate fails your freshness policy. Reusing a cached rate must not turn its timestamp into “now.”
Keep an indicative display separate from a payment quote. If the interface lets someone accept a payable amount, determine and retain that amount on the server under your own pricing rules; do not recalculate it from the visitor's formatted text. SiftingIO supplies reference market data, not payment execution or a guaranteed settlement rate. Customer-facing use must match your plan's usage rights.
Start with one currency pair and the tests above. Then create your API key, replace the synthetic rate with verified data and add only the currencies your application actually supports. The conversion should change the amount; the formatter should only change how the reader sees it.



