Exact money arithmetic for Swift. An amount is a whole number of its currency's smallest unit, so there is no floating point and no hidden rounding. The currency is part of the type when you know it at compile time, and checked at runtime when you do not.
import SwiftMoneyCore
let price = GBP(minorUnits: 4_99) // GBP 4.99
let vat = price.applying("20%").rounded(.toNearestOrEven) // GBP 1.00
let total = price + vat // GBP 5.99- Exact by construction. Amounts are
Int64minor units. Rates are decimal fixed point.Doublenever touches arithmetic. - Two levels of currency safety.
GBP + EURfails to compile.Moneycarries its currency at runtime, and mixing two currencies throws. - One rounding step, at the end. Chain exact operations with
unrounded, then settle once. - Splits that always add up. Even and weighted splits return parts that sum exactly to the original amount.
- 165 ISO 4217 currencies built in, every code with a stated minor unit, and a custom currency takes three lines.
- Locale-aware formatting and parsing, in a separate Foundation module.
- Exact JSON by default, with configurable wire formats.
- Zero dependencies. The core imports nothing, not even Foundation. Every value the library
produces is
Sendable.
Add the package in Package.swift:
dependencies: [
.package(url: "https://github.com/PaulRWillis/swift-money.git", from: "0.6.2"),
]Then add the product your target needs. For most apps the umbrella product is all you need:
.target(
name: "MyTarget",
dependencies: [
.product(name: "SwiftMoney", package: "swift-money"),
]
),import SwiftMoney // the core types and the Foundation-backed formatting, in one importSwiftMoney re-exports three libraries you can also depend on directly:
SwiftMoneyCorehas the core money types. It has no Foundation dependency, so it is the one to use on Embedded Swift.SwiftMoneyLocalizationhas the locale currency formats, generated from CLDR. It has no Foundation dependency either, and builds for Embedded Swift.SwiftMoneyFoundationhas the pieces that need Foundation: formatting, localized parsing,Decimalinterop, and JSON configuration.
Converting an amount to another currency lives in SwiftMoneyFX, which SwiftMoney does not
re-export, so converting always takes its own dependency and import SwiftMoneyFX:
.product(name: "SwiftMoneyFX", package: "swift-money"),Exchange rates themselves are in the core. SwiftMoneyFX builds for Embedded Swift too.
Requires Swift 6.2. Platforms: macOS 15+, iOS 18+, watchOS 11+, tvOS 18+, visionOS 2+. Linux builds in CI.
GBP, EUR, USD and JPY are ready to use. The other ISO 4217 currencies live in the
Currencies namespace; give the ones you use a short name:
let price = GBP(minorUnits: 4_99) // GBP 4.99
let fare = JPY(minorUnits: 499) // JPY 499
typealias CHF = MoneyOf<Currencies.CHF>
let fee = CHF(minorUnits: 12_50) // CHF 12.50Amounts count the currency's smallest unit: pence, cents, yen. init(minorUnits:) takes any
integer type and traps when the value does not fit. For values from outside the program,
init?(exactly:) returns nil instead:
GBP(exactly: unvalidatedAmount) // nil when the amount does not fitTo count whole units instead, init?(majorUnits:) reads the currency's scale for you, so a count of
pounds is never taken as pence. It returns nil when the amount is too large:
GBP(majorUnits: 15) // GBP 15.00
Money(majorUnits: 15, currency: .jpy) // JPY 15To hand the count to an API that takes a plain integer, read it back out:
Int(minorUnitsOf: price) // 499, or nil when the count does not fitlet price = GBP(minorUnits: 10_00)
let tax = GBP(minorUnits: 2_00)
price + tax // GBP 12.00
price - tax // GBP 8.00
price * 3 // GBP 30.00
-price // GBP -10.00
let refund = -price
refund.isNegative // true
refund.isPositive // false
refund.magnitude // GBP 10.00Amounts with a compile-time currency compare, sort, and total:
let expenses = [GBP(minorUnits: 5_20), GBP(minorUnits: -2_40), GBP(minorUnits: 3_38)]
expenses.max() // GBP 5.20
expenses.total() // GBP 6.18Overflow traps, exactly as Int overflow does. There is no money * money, because money
squared has no meaning.
When the currency only arrives at runtime, use Money. It stores the currency inside the value,
and combining two amounts throws when their currencies differ. One try covers a whole
expression:
let price = Money(minorUnits: 4_99, currency: .gbp)
let delivery = Money(minorUnits: 2_00, currency: .gbp)
let total = try (price * 3) + delivery // GBP 16.97
let euros = Money(minorUnits: 5_00, currency: .eur)
try price + euros // throws MoneyError.currencyMismatch(lhs: .gbp, rhs: .eur)Money has no <, because no order exists between five pounds and five euros. Sort through the
throwing comparison instead, and take the larger or smaller with a throwing max or min:
let prices = [price, delivery]
let ordered = try prices.sorted { try $0.isLessThan($1) } // [GBP 2.00, GBP 4.99]
let higher = try max(price, delivery) // GBP 4.99
let dearest = try prices.max() // GBP 4.99, or nil if emptyLimits and slider settings usually arrive from a server. Parse them once, when you decode the
response, into a value whose currency is already checked. After that, most uses need no try.
// A slider's bounds and step, parsed in one call that throws one error
var steps = try Money.Steps(
checkedBounds: (lower: response.minimum, upper: response.maximum),
by: response.step
)
// The saved amount, rounded onto the nearest step
let selection = try Money.Steps.Selection(approximating: saved, in: steps)
selection.amount
selection.selecting(0) // the first step, or nil for a position not in the stepsA range of runtime amounts is built with a throwing ... or ..<, which checks the currencies
and the order once. A typed range needs only the order checked, so bounds from a server never trap:
var limits = try minimum...maximum // ClosedMoneyRange
let typed = try ClosedRange(checkedBounds: (lower: low, upper: high)) // ClosedRange<GBP>
try limits.contains(amount)
try amount.clamped(to: limits)
typed.contains(GBP(minorUnits: 50_00)) // no try: one currencyOne bound has no order to check, so building a partial range with minimum..., ...maximum or
..<maximum never throws, and each has a currency. Every range converts between runtime and
typed amounts:
let atLeast = minimum... // PartialMoneyRangeFrom
atLeast.currency // minimum's currency
ClosedMoneyRange(typed) // typed to runtime: no try
PartialMoneyRangeFrom(low...)
try ClosedRange<GBP>(limits) // runtime to typed: throws unless limits is in pounds
try PartialRangeUpTo<GBP>(..<maximum)
MoneyRange(limits) // to half-open: nil if limits holds the largest amountEach call throws one exact error type, so a switch over it can be exhaustive. Building a range
throws MoneyRangeParsingError. Only runtime bounds can be in two currencies, so a typed range's
switch needs no .currencyMismatch case:
do throws(MoneyRangeParsingError<AnyCurrency>) {
limits = try minimum...maximum
} catch {
switch error {
case .invertedBounds(let lower, let upper): … // minimum was above maximum
case .currencyMismatch(let currency): … // maximum's currency differed
}
}Building steps throws MoneyStepsParsingError, which adds a zero stride and too many steps to
count:
do throws(MoneyStepsParsingError<AnyCurrency>) {
steps = try Money.Steps(checkedBounds: (lower: minimum, upper: maximum), by: step)
} catch {
switch error {
case .currencyMismatch(let currency): … // maximum's, or else step's, currency differed
case .invertedBounds, .zeroStride, .tooManySteps: …
}
}Once a range is built, a call whose only failure is an amount in another currency, such as
contains or converting a runtime range to a typed one, throws MoneyError, as arithmetic does.
A runtime range has no ~=, because it would have to throw and a switch pattern can't be marked
try. Match one with a guard instead: case _ where try limits.contains(amount):.
There is no clamp to a half-open range, a..<b or ..<b. Either can hold no amount (a..<a, or
..< the smallest amount), so the clamp would be failable, and a runtime one would be failable and
throwing. To clamp to a band built with ..< between two amounts, convert it to a closed range
first: try ClosedMoneyRange(band).map { try amount.clamped(to: $0) }.
Steps always end exactly on the far bound, so the largest amount allowed can always be chosen. They are never empty. A negative stride counts down from the upper bound:
let limits = GBP(minorUnits: 10_00)...GBP(minorUnits: 250_00)
try limits.steps(by: .majorUnits(100)) // GBP 10.00, 110.00, 210.00, 250.00
try limits.steps(by: .majorUnits(-100)) // GBP 250.00, 150.00, 50.00, 10.00An amount between two steps is rounded onto one. By default it takes the nearest step, which never
fails: an amount beyond the steps takes the nearer end, and a tie goes to the step at an even
position from the lower bound, or with tiesTo: .awayFromZero to the one larger in size. A rule
that names a direction, such as .down, throws MoneyStepsRoundingError.outOfBounds when no step
satisfies it:
let steps = try limits.steps(by: .majorUnits(10))
let saved = GBP(minorUnits: 123_45)
steps.index(approximating: saved) // the nearest step
steps.index(approximating: saved, tiesTo: .awayFromZero) // the nearest, a tie away from zero
try steps.index(approximating: saved, rounding: .down) // never above the saved amount
steps.firstIndex(of: saved) // an exact match only, or nilstride(from:through:by:) and stride(from:to:by:) work too, with the standard library's behavior
for integers: they stop at the last step that fits, so 10 through 250 by 100 leaves out 250. Use
steps for a slider.
A stride is a non-zero amount to step by. A single unit reads the currency's scale for you, so a pound is never mistaken for a hundred yen:
GBP.Stride.majorUnit // GBP 1.00
JPY.Stride.majorUnit // JPY 1
GBP.Stride.minorUnits(50) // GBP 0.50
Money.Stride.majorUnit(of: amount) // one major unit in the amount's currency
Money.Stride(exactly: serverStep) // nil when the step is zeroA minor unit is still a minor unit: Money(minorUnits: 1_00, currency: .jpy) is JPY 100.
Formatting is locale-aware and lives in SwiftMoneyFoundation:
import SwiftMoneyCore
import SwiftMoneyFoundation
let price = GBP(minorUnits: 4_99)
price.formatted() // "£4.99" for a user in the UK
price.formatted(.currency(locale: Locale(identifier: "en_GB"))) // "£4.99"The style does not need a currency code, because the amount carries its own. A style is also a value you can configure and keep, with the vocabulary of Foundation's currency styles: presentation, grouping, sign, precision, and display rounding.
let style = CHF.FormatStyle(locale: Locale(identifier: "en_CH")).rounded(increment: 5)
style.format(CHF(minorUnits: 4_98)) // "CHF 5.00", Swiss cash roundingFor most locales the style renders without ICU, from locale data generated out of CLDR: faster, and
with far less allocation. It also keeps the currency symbol in a case where
Decimal.FormatStyle.Currency drops it — grouping turned off beside a sign or separator. Any locale
it does not cover falls back to the system formatter, so output there is unchanged. The ones it does
not cover, and why, are listed in
UnsupportedLocales.md.
A Rate scales an amount. Write one as a percent, a fraction, or a decimal:
let vat: Rate = "20%" // equal to "1/5" and "0.2"
Rate(string: "1/3") // from runtime text; nil when invalidApplying a rate keeps the fraction of a unit until you settle it, so you choose the rounding at the point it happens:
price.applying("20%").rounded(.toNearestOrEven) // GBP 1.00Chain exact steps with unrounded and settle once, so rounding error cannot accumulate:
let net = GBP(minorUnits: 4_99)
let delivery = GBP(minorUnits: 2_00)
let payable = (net.unrounded * "17.5%" + delivery.unrounded)
.rounded(.toNearestOrAwayFromZero)Splits return parts that sum exactly to the original, with the leftover minor units spread one per part:
let bill = GBP(minorUnits: 100_00)
Array(bill.split(into: 3).amounts) // [GBP 33.34, GBP 33.33, GBP 33.33]
bill.split(by: [60, 30, 10]) // [GBP 60.00, GBP 30.00, GBP 10.00]A weighted split keeps weight order and hands leftovers to the largest remainders, so each part sits within one minor unit of its exact share.
The core parser reads two exact forms and no locale:
GBP(string: "4.99") // GBP 4.99: a decimal point means major units
GBP(string: "499") // GBP 4.99: no decimal point means minor units
Money(string: "EUR 20.00") // the ISO 4217 table supplies the scaleEach initializer is failable. A decimal too fine for the currency returns nil, never a rounded
value. description prints the same coded form, "GBP 4.99", so values round-trip through
logs.
Localized input goes through the format style's parse strategy:
let style = GBP.FormatStyle(locale: Locale(identifier: "en_GB"))
try style.parseStrategy.parse("£4.99") // GBP 4.99An amount encodes as one exact string by default, so no reader can misplace a decimal point:
try JSONEncoder().encode(GBP(minorUnits: 4_99)) // "GBP 499"Wire shape is a property of the coder, configured in SwiftMoneyFoundation:
let encoder = JSONEncoder()
encoder.moneyCodingFormat = .fields
try encoder.encode(price) // {"currency":"GBP","amount":499}Decoding accepts every default shape the library writes, and the payload itself decides which
arrived. Two choices must also be set on the decoder, because the payload cannot carry them:
custom field keys, and the units that digits without a . count. A . always means major units.
let decoder = JSONDecoder()
decoder.moneyCodingFormat = .codedString(.majorUnits)
try decoder.decode(GBP.self, from: Data(#""GBP 15""#.utf8)) // £15.00, rather than 15pGBP(string: "15", units: .majorUnits) does the same outside Codable.
import SwiftMoneyFoundation
Decimal(majorUnitsOf: GBP(minorUnits: 4_99)) // exactly 4.99
GBP(majorUnits: decimalAmount) // nil rather than round when the value is too fineA currency is a code plus a unit scale, the count of smallest units in one major unit:
enum LoyaltyPoints: CurrencyType {
static let currency = Currency(code: "LTY", unitScale: 1)
}
typealias Points = MoneyOf<LoyaltyPoints>
let balance = Points(minorUnits: 250) // LTY 250For a currency known only at runtime, build a Currency value and use Money. A scale must be a
power of ten, from 1 to 10^18: 1, 100, and 100,000,000 all work, while 5 and 256 do not. Every
ISO 4217 currency is decimal, and so is every cryptocurrency in practice.
- Overflow traps; bad data throws. Arithmetic overflow is a programming error and traps like
Int. A currency mismatch is bad data and throwsMoneyError, the caller's to handle. - Literals trap; data is failable. Every literal that can be invalid traps at the source line. Every runtime input has a failable or throwing twin.
Doublenever touches arithmetic. Fractions go throughRate, decimal fixed point.- No global state. There is no currency registry. Every type is a value, and every value the
library produces is
Sendable.
Storing an amount. Swift code usually holds money in one of three ways, and each has a catch:
Doubleis the classic bug.0.1 + 0.2is not0.3, so amounts drift and a total lands a penny out.Decimalis exact but slow. It allocates memory on every operation, and still holds no currency.- A plain
Intof pennies or cents is exact and fast, but nothing stops you adding pounds to dollars.
SwiftMoney is that last approach made safe. It is exact, as fast as a raw Int, and allocates nothing,
and the currency is part of the value, so adding GBP to USD does not compile. Fractions stay exact until
you choose where to round, so a run of calculations cannot quietly lose a penny. The
benchmarks have the numbers,
measured against Int, Double, Decimal and ordo-one/FixedPoint.
Formatting for a locale. Swift already formats currency through Foundation, so why include another formatter? Because Foundation's has two problems for money:
- With some option combinations it drops the currency symbol and prints a bare number, for example with grouping turned off next to a plus or minus sign. A missing symbol on an amount is dangerous.
- Underneath it uses ICU, which on Linux is not safe to call from several threads at once and can return wrong output under concurrent use.
- It is built for standard ISO currencies, so it cannot properly format a custom one, such as loyalty points or in-game credits. It loses the symbol or shows the wrong number of decimals.
For the locales it covers, SwiftMoney formats amounts itself, without ICU. It keeps the symbol, is safe from any thread, and is faster with no allocation. It also formats your own custom currencies, at the scale you gave them. Any other locale falls back to Foundation, so nothing breaks, and there it is no faster than Foundation.
swift build
swift testBenchmarks live in a separate package and need jemalloc (brew install jemalloc):
swift package --package-path Benchmarks --disable-sandbox benchmarkSee BENCHMARKS.md for the recorded results.
Apache License 2.0. See LICENSE for details, and ACKNOWLEDGMENTS.md for the sources that influenced this library.