SwiftMoney

0.39.1

Type-safe money in Swift with no floats, rounding bugs, or cross-currency accidents
PaulRWillis/swift-money

What's New

0.39.1

2026-10-10T11:19:41Z

0.39.1 (2026-10-10)

Bug Fixes

  • CLDRLocaleIdentifiers: split names on the hyphen byte, so the generator parses as the runtime does (4c72d6d)
  • localization: return nil for a plural category outside the locale table (9c3cd5d)

Performance Improvements

  • localization: drop the unused Sequence conformances from the lookup keys (8a975a7)
  • localization: keep the subtag parse and the chain's step out of every copy (dcd83e6)
  • localization: stop reading a language subtag past 8 bytes (4ba610e)
  • localization: stop reading a subtag once it can't be a script or region (d4ca150)

SwiftMoney

License CI coverage

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

Features

  • Exact by construction. Amounts are Int64 minor units. Rates are decimal fixed point. Double never touches arithmetic.
  • Two levels of currency safety. GBP + EUR fails to compile. Money carries 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.

Installation

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 import

SwiftMoney re-exports three libraries you can also depend on directly:

  • SwiftMoneyCore has the core money types. It has no Foundation dependency, so it is the one to use on Embedded Swift.
  • SwiftMoneyLocalization has the locale currency formats, generated from CLDR. It has no Foundation dependency either, and builds for Embedded Swift.
  • SwiftMoneyFoundation has the pieces that need Foundation: formatting, localized parsing, Decimal interop, 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.

Creating money

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.50

Amounts 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 fit

To 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 15

To 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 fit

Arithmetic

let 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.00

Amounts 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.18

Overflow traps, exactly as Int overflow does. There is no money * money, because money squared has no meaning.

Runtime currencies

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 empty

Ranges

Limits 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 steps

A 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 currency

One 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 amount

Each 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

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.00

An 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 nil

stride(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.

Strides

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 zero

A minor unit is still a minor unit: Money(minorUnits: 1_00, currency: .jpy) is JPY 100.

Formatting for display

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 rounding

For 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.

Percentages and fractions

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 invalid

Applying 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.00

Chain 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)

Splitting

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.

Parsing text

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 scale

Each 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.99

JSON

An 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 15p

GBP(string: "15", units: .majorUnits) does the same outside Codable.

Decimal interop

import SwiftMoneyFoundation

Decimal(majorUnitsOf: GBP(minorUnits: 4_99))   // exactly 4.99
GBP(majorUnits: decimalAmount)                 // nil rather than round when the value is too fine

Custom currencies

A 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 250

For 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.

Design

  • Overflow traps; bad data throws. Arithmetic overflow is a programming error and traps like Int. A currency mismatch is bad data and throws MoneyError, 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.
  • Double never touches arithmetic. Fractions go through Rate, decimal fixed point.
  • No global state. There is no currency registry. Every type is a value, and every value the library produces is Sendable.

How it compares

Storing an amount. Swift code usually holds money in one of three ways, and each has a catch:

  • Double is the classic bug. 0.1 + 0.2 is not 0.3, so amounts drift and a total lands a penny out.
  • Decimal is exact but slow. It allocates memory on every operation, and still holds no currency.
  • A plain Int of 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.

Building and testing

swift build
swift test

Benchmarks

Benchmarks live in a separate package and need jemalloc (brew install jemalloc):

swift package --package-path Benchmarks --disable-sandbox benchmark

See BENCHMARKS.md for the recorded results.

License

Apache License 2.0. See LICENSE for details, and ACKNOWLEDGMENTS.md for the sources that influenced this library.

Description

  • Swift Tools 6.2.0
View More Packages from this Author

Dependencies

Last updated: Sun Oct 11 2026 19:25:38 GMT-0900 (Hawaii-Aleutian Daylight Time)