Modeling money, currencies & exchange rates using JavaScript
Working with money, currencies and exchange rates is pretty common, yet it's no easy task no matter the language. While JavaScript doesn't have a lot of useful features built into it, Intl
can give us a head start with some parts of the process.
Modeling currency
The first step in modeling any sort of monetary value is to have a structure for currency. Luckily, Intl
has this problem solved for us. You can use Intl.NumberFormat
with style: 'currency'
to get a formatter for a specific currency. This formatter can then be used to format a number into a currency string.
Setting up currency information
In order to retrieve all supported currencies, we can use Intl.supportedValuesOf()
with 'currency'
as the argument. This will return an array of the ISO 4217 currency codes supported by the environment. Then, using Map()
, Array.prototype.map()
and Intl.NumberFormat
, we can create an object for all currencies, that will format values on demand.
const isoCodes = Intl.supportedValuesOf('currency'); const currencyFields = ['symbol', 'narrowSymbol', 'name']; const allCurrencies = new Map( isoCodes.map(code => { const format = currencyDisplay => value => Intl.NumberFormat(undefined, { style: 'currency', currency: code, currencyDisplay, }) .format(value) .trim(); return [code, Object.freeze({ code, format })]; }) ); // Returns a Map object with all currency information // { // 'USD': { code: 'USD', format: [Function] }, // 'EUR': { code: 'EUR', format: [Function] }, // ... // }
Notice how Object.freeze()
is used to prevent the object from being modified. This is important because we don't want to accidentally change the currency information.
Retrieving currency objects
Having set up all the currency information, we need a way to retrieve it when we need it. Getting the same currency object for the same currency code will be important later down the line for comparisons. As we have a Map
object, we can use Map.prototype.get()
to retrieve the currency object. As a safeguard, we should ensure the currency code matches the key, using String.prototype.toUpperCase()
.
const getCurrencyFromCode = code => { const isoCode = code.toUpperCase(); return allCurrencies.get(isoCode); }; const currency = getCurrencyFromCode('usd'); currency.format('symbol')(1000); // '$1,000.00'
We can then create a simple static class to handle currency retrieval. Apart from a simple get()
method, we can also add a wrap()
method. This method will return the same currency object if it's passed as an argument. Otherwise, it will retrieve the currency object using the get()
method.
class Currency { static get(code) { const currency = getCurrencyFromCode(code); if (!currency) throw new RangeError(`Invalid currency ISO code "${code}"`); return currency; } static wrap(currency) { if ( typeof currency === 'object' && getCurrencyFromCode(currency.code) === currency ) return currency; return Currency.get(currency); } } const usd = Currency.get('usd'); usd.format('symbol')(1000); // '$1,000.00' const usd2 = Currency.wrap(usd); usd === usd2; // true
Modeling money
A monetary value is simply a data structure that contains a value and a currency. Implementing a class for that is fairly simple, using the Currency
class from before. We can then use the currency object to format the value as needed.
class Money { value; currency; constructor(value, currency) { this.value = Number.parseFloat(value); this.currency = Currency.wrap(currency); } format(currencyDisplay = 'symbol') { return this.currency.format(currencyDisplay)(this.value); } } const money = new Money(1000, 'usd'); money.format(); // '$1,000.00' money.format('code'); // 'USD 1,000.00'
Mathematical operations with money
Performing mathematical operations with money is a bit more complex. We need to ensure that the currency is the same for both operands. We'll later cover how to handle exchange rates, but for now, we'll focus on the basic operations.
Oddly enough, multiplication and division are the low hanging fruits, as you can only multiply or divide by a scalar value. Similarly, the modulo and quotient operations can be implemented without any additional complexity.
class Money { // ... multiply(num) { return new Money(this.value * num, this.currency); } divide(num) { return new Money(this.value / num, this.currency); } div(num) { return new Money(Math.floor(this.value / num), this.currency); } mod(num) { return new Money(this.value % num, this.currency); } divmod(num) { return [this.div(num), this.mod(num)]; } } const money = new Money(1000, 'usd'); money.multiply(2).format(); // '$2,000.00' money.divide(2).format(); // '$500.00' money.div(3).format(); // '$333.00' money.mod(3).format(); // '$1.00'
Addition and subtraction are the tough ones. We need to ensure that the currency is the same for both operands, which can be done by comparing the currency codes. If it's not, we can throw an error for the time being.
class Money { // ... add(money) { if (this.currency !== money.currency) throw new Error('Cannot add money with different currencies'); return new Money(this.value + money.value, this.currency); } subtract(money) { if (this.currency !== money.currency) throw new Error('Cannot subtract money with different currencies'); return new Money(this.value - money.value, this.currency); } } const money1 = new Money(1000, 'usd'); const money2 = new Money(500, 'usd'); money1.add(money2).format(); // '$1,500.00' money1.subtract(money2).format(); // '$500.00'
Finally, equality and comparison operations can be implemented by comparing the values and the currency codes. Again, we'll have to deal with the currency codes being different, but we'll throw an error for now.
class Money { // ... equals(money) { if (this.currency !== money.currency) throw new Error('Cannot compare money with different currencies'); return this.value === money.value; } greaterThan(money) { if (this.currency !== money.currency) throw new Error('Cannot compare money with different currencies'); return this.value > money.value; } lessThan(money) { if (this.currency !== money.currency) throw new Error('Cannot compare money with different currencies'); return this.value < money.value; } } const money1 = new Money(1000, 'usd'); const money2 = new Money(500, 'usd'); money1.equals(money2); // false money1.greaterThan(money2); // true money1.lessThan(money2); // false
Modeling exchange rates
An exchange rate is simply a ratio between two currencies. Instead of modeling it as a class, using a wrapper object that contains multiple exchange rates provides more utility. We'll be calling this object a Bank
.
Creating a bank
The Bank
class will contain a Map
object that maps currency pairs to exchange rates. We can add exchange rates using Map.prototype.set()
and retrieve them using Map.prototype.get()
. In order to keep things neat and ensure we can pass either currencies or ISO codes, we can use the Currency.wrap()
method from before.
class Bank { exchangeRates; constructor() { this.exchangeRates = new Map(); } setRate(from, to, rate) { const fromCurrency = Currency.wrap(from); const toCurrency = Currency.wrap(to); const exchangeRate = Number.parseFloat(rate); this.exchangeRates.set( `${fromCurrency.code} -> ${toCurrency.code}`, exchangeRate ); return this; } getRate(from, to) { const fromCurrency = Currency.wrap(from); const toCurrency = Currency.wrap(to); return this.exchangeRates.get( `${fromCurrency.code} -> ${toCurrency.code}` ); } } const bank = new Bank(); bank.setRate('usd', 'eur', 0.85); bank.getRate('usd', 'eur'); // 0.85
Converting money
Converting money from one currency to another is a matter of multiplying the value by the exchange rate. The responsibility for exchanging money is part of the Bank
class, as it's the one that holds the exchange rates.
class Bank { // ... exchange(money, to) { if (!(money instanceof Money)) throw new TypeError(`${money} is not an instance of Money`); const toCurrency = Currency.wrap(to); if (toCurrency === money.currency) return money; const exchangeRate = this.getRate(money.currency, toCurrency); if (!exchangeRate) throw new TypeError( `No exchange rate found for ${money.currency.code} to ${toCurrency.code}` ); return new Money(money.value * exchangeRate, toCurrency); } } const bank = new Bank(); bank.setRate('usd', 'eur', 0.85); const money = new Money(1000, 'usd'); bank.exchange(money, 'eur').format(); // '€850.00'
Making money exchangeable
Using the Bank
class to exchange money works, but it's a lot of work to do every time. The more practical scenario would be to reference a Bank
instance from each Money
object and use it to exchange money.
class Money { value; currency; bank; constructor(value, currency, bank) { this.value = Number.parseFloat(value); this.currency = Currency.wrap(currency); if (!(bank instanceof Bank)) throw new TypeError(`${bank} is not an instance of Bank`); this.bank = bank; } // ... exchangeTo(currency) { return this.bank.exchange(this, currency); } } const bank = new Bank(); bank.setRate('usd', 'eur', 0.85); const money = new Money(1000, 'usd', bank); money.exchangeTo('eur').format(); // '€850.00'
However, passing the Bank
instance every time we create a Money
object is not practical. In most scenarios, you'll only ever have a single instance, which can be easily added to the class as a static property. This allows for Money
instances to default to the same instance, making exchanges easier.
class Bank { static defaultBank; // ... } class Money { // ... constructor(value, currency, bank = Bank.defaultBank) { this.value = Number.parseFloat(value); this.currency = Currency.wrap(currency); if (!(bank instanceof Bank)) throw new TypeError(`${bank} is not an instance of Bank`); this.bank = bank; } // ... } const bank = new Bank(); bank.setRate('usd', 'eur', 0.85); Bank.defaultBank = bank; const money = new Money(1000, 'usd'); money.exchangeTo('eur').format(); // '€850.00'
Mathematical operations with exchange rates
Having set up exchange rates, we can now perform mathematical operations with money in different currencies. We need only check if two Money
objects are in the same currency before performing the operation. If they're not, we can exchange one of them to the other currency.
class Money { // ... add(money) { if (this.currency !== money.currency) money = money.exchangeTo(this.currency); return new Money(this.value + money.value, this.currency); } subtract(money) { if (this.currency !== money.currency) money = money.exchangeTo(this.currency); return new Money(this.value - money.value, this.currency); } equals(money) { if (this.currency !== money.currency) money = money.exchangeTo(this.currency); return this.value === money.value; } greaterThan(money) { if (this.currency !== money.currency) money = money.exchangeTo(this.currency); return this.value > money.value; } lessThan(money) { if (this.currency !== money.currency) money = money.exchangeTo(this.currency); return this.value < money.value; } // ... } const bank = new Bank(); bank.setRate('usd', 'eur', 0.85); bank.setRate('eur', 'usd', 1.18); Bank.defaultBank = bank; const money1 = new Money(1000, 'usd'); const money2 = new Money(500, 'eur'); money1.add(money2).format(); // '$1,590.00' money1.subtract(money2).format(); // '$410.00' money1.equals(money2); // false money1.greaterThan(money2); // true money1.lessThan(money2); // false
Summary
Implementing a basic structure for money, currencies and exchange rates is a lot of work, but it's fairly straightforward once you get the basics down. There's plenty of improvements that you can make to this implementation, such as adding more mathematical operations, or handling historical exchange rates. However, this should give you a good starting point for any project that requires handling money.
View the complete implementation
const isoCodes = Intl.supportedValuesOf('currency'); const currencyFields = ['symbol', 'narrowSymbol', 'name']; const allCurrencies = new Map( isoCodes.map(code => { const format = currencyDisplay => value => Intl.NumberFormat(undefined, { style: 'currency', currency: code, currencyDisplay, }) .format(value) .trim(); return [code, Object.freeze({ code, format })]; }) ); const getCurrencyFromCode = code => { const isoCode = code.toUpperCase(); return allCurrencies.get(isoCode); }; class Currency { static get(code) { const currency = getCurrencyFromCode(code); if (!currency) throw new RangeError(`Invalid currency ISO code "${code}"`); return currency; } static wrap(currency) { if ( typeof currency === 'object' && getCurrencyFromCode(currency.code) === currency ) return currency; return Currency.get(currency); } }
class Bank { static defaultBank; exchangeRates; constructor() { this.exchangeRates = new Map(); } setRate(from, to, rate) { const fromCurrency = Currency.wrap(from); const toCurrency = Currency.wrap(to); const exchangeRate = Number.parseFloat(rate); this.exchangeRates.set( `${fromCurrency.code} -> ${toCurrency.code}`, exchangeRate ); return this; } getRate(from, to) { const fromCurrency = Currency.wrap(from); const toCurrency = Currency.wrap(to); return this.exchangeRates.get( `${fromCurrency.code} -> ${toCurrency.code}` ); } exchange(money, to) { if (!(money instanceof Money)) throw new TypeError(`${money} is not an instance of Money`); const toCurrency = Currency.wrap(to); if (toCurrency === money.currency) return money; const exchangeRate = this.getRate(money.currency, toCurrency); if (!exchangeRate) throw new TypeError( `No exchange rate found for ${money.currency.code} to ${toCurrency.code}` ); return new Money(money.value * exchangeRate, toCurrency); } }
class Money { value; currency; bank; constructor(value, currency, bank = Bank.defaultBank) { this.value = Number.parseFloat(value); this.currency = Currency.wrap(currency); if (!(bank instanceof Bank)) throw new TypeError(`${bank} is not an instance of Bank`); this.bank = bank; } format(currencyDisplay = 'symbol') { return this.currency.format(currencyDisplay)(this.value); } exchangeTo(currency) { return this.bank.exchange(this, currency); } add(money) { if (this.currency !== money.currency) money = money.exchangeTo(this.currency); return new Money(this.value + money.value, this.currency); } subtract(money) { if (this.currency !== money.currency) money = money.exchangeTo(this.currency); return new Money(this.value - money.value, this.currency); } multiply(num) { return new Money(this.value * num, this.currency); } divide(num) { return new Money(this.value / num, this.currency); } div(num) { return new Money(Math.floor(this.value / num), this.currency); } mod(num) { return new Money(this.value % num, this.currency); } divmod(num) { return [this.div(num), this.mod(num)]; } equals(money) { if (this.currency !== money.currency) money = money.exchangeTo(this.currency); return this.value === money.value; } greaterThan(money) { if (this.currency !== money.currency) money = money.exchangeTo(this.currency); return this.value > money.value; } lessThan(money) { if (this.currency !== money.currency) money = money.exchangeTo(this.currency); return this.value < money.value; } }