Type-Safe Unit Conversions in Swift: A Practical Look at Foundation's Measurement API
Raw Doubles don't know if they're meters or miles. Here's how Foundation's Measurement API moves unit safety into the type system, with a worked speed conversion and the trade-offs to consider.
15 Mar 2026, 02:52 UTC

The Double Doesn't Know It's a Speed
You have a weather app that fetches wind speed in meters per second. Your UI displays miles per hour. Somewhere in the view model, a Double gets multiplied by 2.23694. It works—until a new developer passes a temperature in Celsius into the same helper, or a backend change starts sending kilometers per hour, and the app quietly shows a wind speed of 90 mph during a gentle breeze.
The root cause is that Double carries no information about what it represents. Foundation's Measurement API fixes this by making the unit part of the type. A Measurement<UnitSpeed> cannot be accidentally added to a Measurement<UnitTemperature>, and the compiler enforces it.
How Measurement Moves Units into the Type System
Measurement is a generic struct: Measurement<UnitType>, where UnitType is a Dimension subclass (Dimension itself inherits from Unit). The generic parameter is what gives you compile-time safety. You don't declare a measurement as "a number with a unit"; you declare it as a speed, a length, or a duration.
Foundation ships with dimension subclasses for common physical quantities: UnitSpeed, UnitLength, UnitMass, UnitTemperature, and others. Each subclass defines its base unit and the conversion factors to related units. When you convert between two units in the same dimension, the framework applies the correct factor rather than trusting you to hard-code it.
Worked Example: Meters per Second to Miles per Hour
Suppose you receive a wind speed of 12.5 meters per second from an API. Instead of storing it as a bare Double, wrap it immediately:
import Foundation
let windSpeed = Measurement(value: 12.5, unit: UnitSpeed.metersPerSecond)
// Convert to the unit your UI expects
let mphSpeed = windSpeed.converted(to: .milesPerHour)
// Format for display
let formatter = MeasurementFormatter()
formatter.unitOptions = .providedUnit
print(formatter.string(from: mphSpeed))
The converted(to:) method returns a new Measurement<UnitSpeed>. You cannot pass UnitTemperature.celsius here—the compiler rejects it because UnitTemperature is not the same dimension as UnitSpeed. That is the safety net working before the code ever runs.
When you verify this in a playground or test target, assert against the known factor rather than a magic number. One meter per second equals approximately 2.23694 miles per hour, so 12.5 m/s should land near 27.96 mph. Use a tolerance to account for floating-point representation:
import XCTest
func testSpeedConversion() {
let input = Measurement(value: 12.5, unit: UnitSpeed.metersPerSecond)
let output = input.converted(to: .milesPerHour)
XCTAssertEqual(output.value, 27.96175, accuracy: 0.001)
}
Run this test in your project's test target. If the assertion fails, the conversion factor or initializer signature may differ from what you expect—check the current Apple documentation for UnitSpeed and Measurement.
Custom Units: Powerful, but Handle with Care
If your domain has a quantity Foundation does not model—say, a game resource or a specialized industrial measurement—you can subclass Unit and define a Dimension subclass with a custom converter. The converter's baseUnitValue method must express your custom unit in terms of the dimension's base unit.
This is where the API stops protecting you. The compiler cannot know that your "furlongs per fortnight" converter is mathematically correct. A mistake in baseUnitValue produces silently wrong conversions. If you build custom units, cover them with unit tests that assert round-trip conversions and known reference values.
Trade-offs and Deployment Reality
Adopting Measurement is not free. Each Measurement instance is a small struct wrapping a Double and a unit reference, so it adds allocation overhead compared to raw floating-point math. For inner loops processing millions of sensor samples, that overhead can matter. In typical UI code, it is negligible.
Version support is the other gate. The Measurement API requires iOS 10, macOS 10.12, tvOS 10, or watchOS 3 and later. If your deployment target predates those versions, you need conditional compilation or a fallback to plain numeric types.
| Consideration | Raw Double | Measurement API |
|---|---|---|
| Unit safety | None; runtime bugs | Compile-time enforcement |
| Conversion factors | Hard-coded, error-prone | Provided by Dimension subclass |
| Formatting / localization | Manual | Built-in via MeasurementFormatter |
| Allocation overhead | Minimal | Small struct overhead |
| Minimum OS | All versions | iOS 10+, macOS 10.12+, tvOS 10+, watchOS 3+ |
When to Reach for It
Use Measurement at the boundaries where units enter your system—API response parsing, user input, and display formatting. Keep raw Double only in tight computational loops where you have already validated the units and the allocation cost is measurable. If you support OS versions older than the thresholds above, gate the API behind availability checks.
The practical check is simple: write a small test that converts a known value between two units and asserts the result within a tolerance. If that passes and your compiler now rejects accidental dimension mismatches, the API is doing its job.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.