Swift Foundation's Units and Measurements
A deep-dive in some advantages and limitations of the Measurement API as it currently exists
Introduction
When developing in Swift for Apple platforms, Foundation provides an out-of-the-box implementation for units and measurements, defining a set of physical dimensions that allows for conversion between units and offers formatting for measurements based on precision and rounding of numbers, level of detail, locale, and usage.
The Measurement API has already been covered at length in many different places on the Internet, so I recommend the following links if you want an introduction on how to use Units and Measurements as they exist today:
- Fatbobman's article presents a really great overview on the API and all of it's features with great code snippets, as well as an example of how to format measurements for use in SwiftUI.
- Stewart Lynch's video provides a more step-by-step tutorial on how to use the Measurement API and it's features if you're looking for a more code-along approach.
- TrozWare's article focuses more on the
MeasurementFormatter
type to customize the display of aMeasurement
on Apple Watch.- You can also check out this guide documenting the formatting options available for Measurements.
This post focuses on the advantages and limitations of the current approach available in Foundation and how some architectural tweaks and modern Swift features could make it more flexible, stable and easier to use.
As of this post's writing, it's code does not have a pure Swift implementation yet, being exclusive to Apple platforms and swift-foundation only offering stubs to allow Swift code using it to compile in other platforms. Discussion on the implementation is already underway on Swift forums, but since it's been stagnant since July 2023, so I hope this post can help anyone who's either looking to contribute in the discussion or looking to make their own Measurement package.
What already works well?
As a built-in structure of Swift Foundation, the Measurement API already reduces a lot of the work you're required to do in order to start using measures in your apps.
Type-safe Unit Definition and Conversion
Measurement<UnitType>
is a struct that associates a numeric value with an unit that represents the quantity measured, indicating the domain it belongs to.
This offers a type-safe approach for assigning values that is enforced by the compiler as shown in the example below, which requires that the measurement uses a unit of length.
var distance: Measurement<UnitLength>
distance = Measurement(22, UnitLength.meters) // ✅
distance = Measurement(31, UnitMass.grams) // 🛑 Cannot convert value of type 'UnitMass' to expected argument type 'UnitLength'
The same applies for unit conversions, in which units conforming to the Dimension
type can only convert to units in the same domain.
var weight = Measurement(value: 52, unit: UnitMass.kilograms)
weight.converted(to: .grams) // ✅ 52_000 g
weight.converted(to: .liters) // 🛑 Type 'UnitMass' has no member 'liters'
Easy Extensibility
Given their origins as Objective-C types, Unit
and Dimension
are abstract classes used as the base for the unit systems, implementing new unit types or dimensions via subclassing.
class Amount: Dimension, @unchecked Sendable {
static var units: Self { .init(symbol: "u") }
}
class Force: Dimension, @unchecked Sendable {
static var newtons: Self { .init(symbol: "N") }
}
And, through the use of extensions, you can also add brand new units like shown above, but also create entirely new units, being specifically useful when your app or game has a custom set of context-relevant units to measure it's information.
public extension UnitLength {
static var gizaPyramids: Self {
.init(symbol: "◢◣", converter: UnitConverterLinear(coefficient: 146.6, constant: 0))
}
}
Formatting and Display
When you want to display a measure, Foundation automatically formats your unit based on the device's settings (locale, metric system, etc.). You can also configure it's display format through the formatted()
instance method on Measurement
, which uses a DotSyntax .measurement
to create an instance of FormatStyle
dedicated to the Measurement API:
let volume = Measurement(value: 10, unit: UnitVolume.cubicFeet)
volume.formatted() // "283.168 cm³"
volume.formatted(.measurement(width: .wide)) // "283.168 cubic centimetres"
volume.formatted(.measurement(width: .narrow, usage: .asProvided)) // 10ft³"
volume.formatted(.measurement(width: .abbreviated, usage: .liquid, numberFormatStyle: .number)) // "283 l"
Wide Assortment of Default Units
By default, Foundation provides over 22 specializations of Unit
and/or Dimension
(which amounts to over 200 predefined units), all with included formatting and display options for select unit types.
🔮 Fun Fact: As of writing, the most recent unit added to the Measurement API is milliwattHours, a unit of energy added in OS 26 and meant for use with the brand new EnergyKit... yet the unit is not available for watchOS, tvOS or visionOS and has it's own subtype inside of
UnitEnergy
(UnitEnergy.EnergyKit
)
This gives developers an out-of-the-box solution for most use cases that a traditional app would require. Add this with the easy extensibility for new and existing unit types and you have an API that's user-friendly, battle-tested and well designed for most developer needs.
Except...
Limitations
Well, as mentioned in the description of this post, there are some limitations that make the Measurement API not reach it's full intended potential when looking at it from the perspective of a Swift package.
Objective-C constraints
The current implementation in Swift is a Objective-C implementation into the language, sharing the same logic as the original module: with this, comes some important considerations before using it.
For starters, there's no compile-time enforcement stopping you from instancing Unit
and Dimension
objects in your code, or using them as Measurement
filters, completely breaking the type-safety of the generic type. For example, the snippet below compiles and runs just fine with no errors:
var generalMeasurement = Measurement(value: 32, unit: Unit(symbol: "#"))
generalMeasurement = Measurement(value: 22, unit: UnitArea.ares)
generalMeasurement = Measurement(value: 89, unit: UnitInformationStorage.bits)
You could even attempt to encode and decode the measurement under a generic measurement:
var gameSize = Measurement(value: 40, unit: UnitInformationStorage.bits)
let encoded = try JSONEncoder().encode(gameSize)
var decoded = try JSONDecoder().decode(Measurement<UnitInformationStorage>.self, from: encoded)
decoded = Measurement(value: 20, unit: UnitPower.watts) // 🛑 Cannot convert value of type 'UnitPower' to expected argument type 'UnitInformationStorage'
var decodedWithGeneric = try JSONDecoder().decode(Measurement<Unit>.self, from: encoded)
decodedWithGeneric = Measurement(value: 20, unit: UnitPower.watts)
This could also lead to crashes at runtime, as the compiler's type inference isn't able to identify that these are two different types of Measurement
, with the code below compiling just fine and presenting an error at runtime:
var path = Measurement(value: 32, unit: UnitLength.kilometers)
var surface = Measurement(value: 21, unit: UnitArea.squareMiles)
path < surface // 🛑 Fatal error: Attempt to compare measurements with non-equal dimensions
Even when accounting for Comparable
and rewriting as Measurement<Unit>
instances provides the same results:
var path2 = Measurement<Unit>(value: 32, unit: UnitLength.kilometers)
var surface2 = Measurement<Unit>(value: 21, unit: UnitArea.squareMiles)
path2 < surface2 // 🛑 Fatal error: Attempt to compare measurements with non-equal dimensions
Add in the Strict Concurrency Model, and even such a simple operation as this:
func someOperation(_ measurement: Measurement<UnitLength>) -> UnitLength {
return measurement.unit
}
var height = Measurement(value: 1.82, unit: UnitLength.meters)
let outcome = someOperation(height) // ✅ No issues
Can have issues at runtime by adding a single async
, even if the method has no suspend points:
func someOperation(_ measurement: Measurement<UnitLength>) async -> UnitLength {
return measurement.unit
}
var height = Measurement(value: 1.82, unit: UnitLength.meters)
let outcome = await someOperation(height) // 🛑 error: execution stopped with unexpected state.
Even properly constraining the method to @MainActor
as per requirement of a type inheriting from NSObject
causes the same issue as above.
Dealing with an Objective-C class in Swift can be a very complex challenge already for developers with years of Swift experience given the Strict Concurrency requirements, so moving on to a pure Swift implementation in the future already will reduce many of the headaches listed above.
All of the above is temporary, up until a pure Swift implementation arrives, but there are some other things that
Not SI compliant
Another downside to the current implementation lies in it's default set of physical dimensions available, which does not encapsulate all SI units.
While there's 22 unit types provided with the package, only 5 of the 7 fundamental units are contemplated: Time (under UnitDuration
). We also have a pretty big omission with Force not being an actual specialization in the package given it's pretty useful role in Physics operations, being the basis to compose other already implemented dimensions such as UnitEnergy
and UnitPressure
.
While the Measurement API has over 200 defined units, a sizeable number of them are just a redeclaration of the same unit under a different metric prefix and an adjusted converter to account for the new value. If we want any new unit system to follow SI's metric prefix conventions, we require to implement over 10 definitions per defined unit that we want to support Metric Prefixes on.
For reference, while
UnitInformationStorage
has by far the most amount of defined units in Foundation, the actual meaningful number of units for the domain are 3:bit
,nibble
andbyte
. This is due to the fact it not only implements SI's metric prefixes forbit
andbyte
, but also a Binary Prefix.
Restrictive API
While the API is pretty robust and well-designed, there are some obstacles that could be patched up to provide a more accurate and flexible behaviour.
One example of this is when creating a Measurement
instance: while Measurement
accepts any Double
value, there are cases where constraining the value to another type would allow for more accurate representation in context (e.g. describing a file size using Int
or Decimal
in order to avoid floating-point errors or provide discrete measurement of units).
While the API itself is pretty descriptive and easy to understand by reading the code, it can also become pretty verbose: creating a variable to store a measure of "2m" can require 54 characters in it's smallest, non-explicit form, as shown below:
var x = Measurement(value: 2, unit: UnitLength.meters)
Speaking of less-verbose syntax, another issue lies in the lack of DotSyntax support for units, which is a result of Unit
, Dimension
and it's inherited types defined in the packages being non-generic, non-final
classes.
final class CustomLength: UnitLength, @unchecked Sendable {
static var palms: Self { .init(symbol: "✋") }
}
let failedDotSyntax1 = Measurement<Unit>(value: 23, unit: .palms) // 🛑 Type 'Unit' has no member 'palms'
let failedDotSyntax2 = Measurement<UnitLength>(value: 23, unit: .palms) // 🛑 Type 'UnitLength' has no member 'palms'
let validDotSyntax = Measurement<CustomLength>(value: 23, unit: .palms) // ✅ Success
The implementation above still requires explicit declaration of generics, which limits the permisiveness of the notation.
Some other drawbacks include being unable to create ranges of measures with units conforming to Dimension
and using a UnitConverterLinear
and converting to derived units using the base operations (e.g. Measurement<UnitLength>
* Measurement<UnitLength>
= Measurement<UnitArea>
).
There's probably more examples I could talk about, but overall the API has some areas of improvement so that it can become less diffuse, more visible and permissive.
Conclusion
As with any framework design, there's no absolutes on what is better and the current design of swift-foundation already is pretty great and works for most of use cases that a developer would need in their day-to-day, only requiring a pure Swift implementation to fix most of it's issues.
Due to ABI and source stability requirements it's unlikely that we'll see any big redesign to the current code as it is required to be compatible with the current Foundation. However, we can look into Swift packages made by the community for alternatives made with modern features in mind:
- swift-measures is a very good reimplementation of the Measurement API that takes advantage of Swift features, such as structs, protocols and property wrappers. It also dips into new features like the composition of measures (e.g. Length / Time = Velocity) and adds in missing SI unit types such as Force, Magnetic Flux, Substance Amount and more.
- Physical goes for an unique approach: it builds on top of the current Foundation structure, composing units by chaining numbers with properties and mixing dimensions to create new ones using a base
Physical
protocol. - Units also follows a similar approach to the packages above, creating it's own implementation for composing units together, using a registry system to parse a measurement from a
String
. The package also provides a CLI tool for use in conversions. - Beyond these, I'm also working on my own implementation of Units and Measurements for Game Development purposes, focusing more on ergonomy, type-safety, generics and other Modern Swift features (e.g. Integer Generic Parameters) to provide a more structured approach for units that aids experimentation and prevents bugs and misuses at compile time.
At the end of the day, the best approach is the one that actually fits the requirements of your software, so choosing the right tool for the job (or even implementing your own based on existing solutions) is the way to go.