Skip to content
VersionSize

currency

Formats a monetary amount as a currency string with proper locale and symbol. Handles decimal places automatically based on currency.

Source Code

View Source Code
ts
import type { Money } from './types';

/**
 * Options for currency formatting.
 */
export type CurrencyFormatOptions = {
  locale?: string; // BCP 47 language tag (e.g., 'en-US', 'de-DE')
  maximumFractionDigits?: number; // Maximum decimal places
  minimumFractionDigits?: number; // Minimum decimal places
  style?: 'symbol' | 'code'; // Display style
};

const currencyDecimalsCache = new Map<string, number>();
const currencyTemplateCache = new Map<string, Intl.NumberFormatPart[]>();
const integerFormatterCache = new Map<string, Intl.NumberFormat>();
const pow10Cache = new Map<number, bigint>([[0, 1n]]);

/**
 * Formats a monetary amount as a currency string with proper locale and symbol.
 * Handles decimal places automatically based on currency.
 *
 * @example
 * ```ts
 * const money = { amount: 123456n, currency: 'USD' };
 *
 * currency(money); // '$1,234.56' (default en-US)
 * currency(money, { locale: 'de-DE' }); // '1.234,56 $'
 * currency(money, { style: 'code' }); // 'USD 1,234.56'
 * ```
 *
 * @param money - Money object to format
 * @param options - Formatting options
 * @returns Formatted currency string
 */
export function currency(money: Money, options: CurrencyFormatOptions = {}): string {
  const { locale = 'en-US', maximumFractionDigits, minimumFractionDigits, style = 'symbol' } = options;

  // Get decimal places for currency (default to 2 for most currencies)
  const decimalPlaces = getCurrencyDecimals(money.currency);

  validateStyle(style);

  const maxFractionDigits = validateFractionDigits('maximumFractionDigits', maximumFractionDigits, decimalPlaces);
  const minFractionDigits = validateFractionDigits('minimumFractionDigits', minimumFractionDigits, decimalPlaces);

  if (minFractionDigits > maxFractionDigits) {
    throw new RangeError('minimumFractionDigits must be less than or equal to maximumFractionDigits');
  }

  const scaledAmount = rescaleMinorUnits(money.amount, decimalPlaces, maxFractionDigits);
  const isNegative = scaledAmount < 0n;
  const absScaledAmount = isNegative ? -scaledAmount : scaledAmount;
  const divisor = pow10(maxFractionDigits);

  const whole = absScaledAmount / divisor;
  const rawFraction =
    maxFractionDigits === 0 ? '' : (absScaledAmount % divisor).toString().padStart(maxFractionDigits, '0');
  const fraction = trimFraction(rawFraction, minFractionDigits);

  const integerPart = getIntegerFormatter(locale).format(whole);
  const template = getCurrencyTemplate(locale, money.currency, style, isNegative);

  return buildFromTemplate(template, integerPart, fraction);
}

function buildFromTemplate(template: Intl.NumberFormatPart[], integerPart: string, fractionPart: string): string {
  const hasFraction = fractionPart.length > 0;
  let replacedInteger = false;
  let output = '';

  for (const part of template) {
    if (part.type === 'group') {
      continue;
    }

    if (part.type === 'integer') {
      if (!replacedInteger) {
        output += integerPart;
        replacedInteger = true;
      }

      continue;
    }

    if (part.type === 'decimal') {
      if (hasFraction) {
        output += part.value;
      }

      continue;
    }

    if (part.type === 'fraction') {
      if (hasFraction) {
        output += fractionPart;
      }

      continue;
    }

    output += part.value;
  }

  return output;
}

/**
 * Rescales minor units to the requested fraction precision using half-away-from-zero rounding.
 *
 * @internal
 */
function rescaleMinorUnits(amount: bigint, sourceFractionDigits: number, targetFractionDigits: number): bigint {
  if (sourceFractionDigits === targetFractionDigits) {
    return amount;
  }

  if (targetFractionDigits > sourceFractionDigits) {
    return amount * pow10(targetFractionDigits - sourceFractionDigits);
  }

  const factor = pow10(sourceFractionDigits - targetFractionDigits);
  const quotient = amount / factor;
  const remainder = amount % factor;

  if (remainder === 0n) {
    return quotient;
  }

  const absRemainder = remainder < 0n ? -remainder : remainder;
  const roundAway = absRemainder * 2n >= factor;

  if (!roundAway) {
    return quotient;
  }

  return quotient + (amount >= 0n ? 1n : -1n);
}

function trimFraction(value: string, minimumDigits: number): string {
  if (value.length === 0) {
    return '';
  }

  let end = value.length;

  while (end > minimumDigits && value[end - 1] === '0') {
    end--;
  }

  return value.slice(0, end);
}

function validateStyle(
  style: CurrencyFormatOptions['style'],
): asserts style is NonNullable<CurrencyFormatOptions['style']> {
  if (style !== 'symbol' && style !== 'code') {
    throw new RangeError(`Unsupported currency style: ${String(style)}`);
  }
}

function validateFractionDigits(name: string, value: number | undefined, fallback: number): number {
  if (value == null) {
    return fallback;
  }

  if (!Number.isInteger(value) || value < 0) {
    throw new RangeError(`${name} must be a non-negative integer`);
  }

  return value;
}

function getCurrencyTemplate(
  locale: string,
  currencyCode: string,
  style: NonNullable<CurrencyFormatOptions['style']>,
  isNegative: boolean,
): Intl.NumberFormatPart[] {
  const key = [locale, currencyCode, style, isNegative ? 'neg' : 'pos'].join('\0');
  const cached = currencyTemplateCache.get(key);

  if (cached) {
    return cached;
  }

  const formatter = new Intl.NumberFormat(locale, {
    currency: currencyCode,
    currencyDisplay: style,
    maximumFractionDigits: 1,
    minimumFractionDigits: 1,
    style: 'currency',
  });

  const template = formatter.formatToParts(isNegative ? -1.1 : 1.1);

  currencyTemplateCache.set(key, template);

  return template;
}

function getIntegerFormatter(locale: string): Intl.NumberFormat {
  const cached = integerFormatterCache.get(locale);

  if (cached) {
    return cached;
  }

  const formatter = new Intl.NumberFormat(locale, {
    maximumFractionDigits: 0,
    minimumFractionDigits: 0,
    useGrouping: true,
  });

  integerFormatterCache.set(locale, formatter);

  return formatter;
}

function pow10(exponent: number): bigint {
  const cached = pow10Cache.get(exponent);

  if (cached != null) {
    return cached;
  }

  const computed = 10n ** BigInt(exponent);

  pow10Cache.set(exponent, computed);

  return computed;
}

function getCurrencyDecimals(currencyCode: string): number {
  if (currencyDecimalsCache.has(currencyCode)) {
    return currencyDecimalsCache.get(currencyCode)!;
  }

  const decimals =
    new Intl.NumberFormat('en', { currency: currencyCode, style: 'currency' }).resolvedOptions()
      .maximumFractionDigits ?? 2;

  currencyDecimalsCache.set(currencyCode, decimals);

  return decimals;
}

Features

  • Locale-Aware: Formats according to user's locale preferences
  • Currency Symbols: Displays proper currency symbols ($, €, ¥, etc.)
  • Multiple Styles: Symbol or code display
  • Auto-Decimals: Handles 0, 2, or 3 decimal currencies automatically
  • Type-Safe: Uses Money type for precision
  • Isomorphic: Works in both Browser and Node.js

API

Type Definitions
ts
/**
 * Represents a monetary amount with currency.
 * Amount is stored as bigint (minor units/cents) for precision.
 */
export type Money = {
  readonly amount: bigint; // Amount in minor units (e.g., cents for USD)
  readonly currency: string; // ISO 4217 currency code (e.g., 'USD', 'EUR')
};
ts
function currency(money: Money, options?: CurrencyFormatOptions): string;

Parameters

  • money: Money object { amount: bigint, currency: string }
    • amount: Amount in minor units (cents) as bigint
    • currency: ISO 4217 currency code (e.g., 'USD', 'EUR', 'JPY')
  • options: Optional formatting options
    • locale: BCP 47 language tag (default: 'en-US')
    • style: Display style – 'symbol' or 'code' (default: 'symbol')
    • minimumFractionDigits: Minimum decimal places (non-negative integer)
    • maximumFractionDigits: Maximum decimal places (non-negative integer)

Returns

  • Formatted currency string

Examples

Basic Formatting

ts
import { currency } from '@vielzeug/toolkit';

const money = { amount: 123456n, currency: 'USD' };

currency(money);
// '$1,234.56' (US format with symbol)

Different Locales

ts
import { currency } from '@vielzeug/toolkit';

const money = { amount: 123456n, currency: 'EUR' };

// US English
currency(money, { locale: 'en-US' });
// '€1,234.56'

// German
currency(money, { locale: 'de-DE' });
// '1.234,56 €'

// French
currency(money, { locale: 'fr-FR' });
// '1 234,56 €'

Display Styles

ts
import { currency } from '@vielzeug/toolkit';

const money = { amount: 100000n, currency: 'USD' };

// Symbol (default)
currency(money, { style: 'symbol' });
// '$1,000.00'

// Currency code
currency(money, { style: 'code' });
// 'USD 1,000.00'

Zero-Decimal Currencies

ts
import { currency } from '@vielzeug/toolkit';

// Japanese Yen (no decimals)
const yen = { amount: 1234n, currency: 'JPY' };
currency(yen);
// '¥1,234'

// Korean Won (no decimals)
const won = { amount: 5000n, currency: 'KRW' };
currency(won);
// '₩5,000'

Three-Decimal Currencies

ts
import { currency } from '@vielzeug/toolkit';

// Kuwaiti Dinar (3 decimals)
const kwd = { amount: 123456n, currency: 'KWD' };
currency(kwd);
// 'KD 123.456'

// Bahraini Dinar (3 decimals)
const bhd = { amount: 10000n, currency: 'BHD' };
currency(bhd);
// 'BD 10.000'

Real-World Example: E-commerce Display

ts
import { currency } from '@vielzeug/toolkit';

const products = [
  { name: 'Laptop', price: { amount: 99999n, currency: 'USD' } },
  { name: 'Mouse', price: { amount: 2499n, currency: 'USD' } },
  { name: 'Keyboard', price: { amount: 7999n, currency: 'USD' } },
];

products.forEach((product) => {
  console.log(`${product.name}: ${currency(product.price)}`);
});
// Laptop: $999.99
// Mouse: $24.99
// Keyboard: $79.99

Multi-Currency Support

ts
import { currency } from '@vielzeug/toolkit';

const prices = {
  usd: { amount: 100000n, currency: 'USD' },
  eur: { amount: 85000n, currency: 'EUR' },
  gbp: { amount: 73000n, currency: 'GBP' },
  jpy: { amount: 11000n, currency: 'JPY' },
};

Object.entries(prices).forEach(([code, money]) => {
  console.log(`${code.toUpperCase()}: ${currency(money)}`);
});
// USD: $1,000.00
// EUR: €850.00
// GBP: £730.00
// JPY: ¥11,000

Negative Amounts

ts
import { currency } from '@vielzeug/toolkit';

const refund = { amount: -15000n, currency: 'USD' };

currency(refund);
// '-$150.00' or '($150.00)' depending on locale

Custom Fraction Digits

ts
import { currency } from '@vielzeug/toolkit';

const money = { amount: 100000n, currency: 'USD' };

// Force specific decimal places
currency(money, {
  minimumFractionDigits: 2,
  maximumFractionDigits: 3,
});
// '$1,000.00'

Implementation Notes

  • Amount Storage: Store amounts as bigint in minor units (cents) to avoid floating-point errors
  • Currency Codes: Use ISO 4217 currency codes (3-letter codes like 'USD', 'EUR')
  • Decimal Detection: Automatically determines decimal places based on currency
    • 0 decimals: JPY, KRW, VND, and others
    • 2 decimals: Most currencies (USD, EUR, GBP, etc.)
    • 3 decimals: BHD, KWD, OMR, JOD, and others
  • Intl.NumberFormat: Uses native browser/Node.js API for formatting
  • Locale Format: Follows BCP 47 standard (e.g., 'en-US', 'de-DE', 'fr-FR')
  • Symbol Position: Varies by locale ($ before in US, € after in some European locales)

See Also