Skip to content

Commit 554bd5e

Browse files
rstacpooleclaude
andcommitted
Make Quantity extensible via RawRepresentable struct
Replaces the closed `Quantity` enum with a `RawRepresentable` struct that exposes the same built-in dimensions as static constants, resolving the long-standing TODO about extensibility. Callers can now define their own dimensions (e.g. a `Money` dimension for cost calculations) with a static extension, without forking the package or repurposing an unrelated base quantity like `Amount`: extension Quantity { static let money = Quantity(rawValue: "Money") } All existing call sites are source-compatible: `.Length`-style literals, `[Quantity: Int]` dictionary keys, and the `\\Quantity.rawValue` keypath all continue to work. The one behavioral change is that the synthesized failable `init?(rawValue:)` becomes a non-failable `init(rawValue:)`, since any string is now a valid dimension. Adds QuantityTests covering custom-dimension identity, arithmetic cancellation, and dimension description. Documents the feature under a new 'Custom Dimensions' README section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 633d916 commit 554bd5e

3 files changed

Lines changed: 122 additions & 12 deletions

File tree

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,38 @@ let weeklyCartons = try (workforce * personPickRate).convert(to: carton / .week)
194194
print(weeklyCartons) // Prints '350.0 carton/week'
195195
```
196196

197+
### Custom Dimensions
198+
199+
The built-in `Quantity` dimensions (`Length`, `Mass`, `Time`, etc.) cover the ISQ base
200+
quantities, but you can define your own dimension when none of them fit — for example, a
201+
`Money` dimension for cost calculations. Because `Quantity` is `RawRepresentable`, you can
202+
add one with a static extension:
203+
204+
```swift
205+
extension Quantity {
206+
static let money = Quantity(rawValue: "Money")
207+
}
208+
```
209+
210+
Your dimension then behaves like any built-in one. Here a concrete rate of `$/m^3` is
211+
multiplied by a volume in `m^3`, and the volume cancels to leave a pure cost:
212+
213+
```swift
214+
let registry = try RegistryBuilder().addUnit(
215+
name: "dollar",
216+
symbol: "$",
217+
dimension: [.money: 1]
218+
).registry()
219+
let dollar = try Unit(fromSymbol: "$", registry: registry)
220+
221+
let rate = Measurement(value: 180, unit: dollar / .meter.pow(3)) // $180 per m^3
222+
let volume = Measurement(value: 24, unit: .meter.pow(3)) // 24 m^3
223+
print(rate * volume) // Prints '4320.0 $'
224+
```
225+
226+
Unlike repurposing an unrelated base quantity such as `Amount`, a dedicated dimension can't
227+
silently cancel against unrelated units. Use a unique `rawValue` for each distinct dimension.
228+
197229
## CLI
198230

199231
The easiest way to install the CLI is with brew:

Sources/Units/Quantity.swift

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,32 @@
1-
/// A dimension of measurement. These may be combined to form composite dimensions and measurements
2-
public enum Quantity: String, Sendable {
3-
// TODO: Consider changing away from enum for extensibility
1+
/// A dimension of measurement. These may be combined to form composite dimensions and measurements.
2+
///
3+
/// `Quantity` is `RawRepresentable` rather than an enum so that callers can define their own
4+
/// dimensions without modifying this package. Add one with a static extension:
5+
///
6+
/// ```swift
7+
/// extension Quantity {
8+
/// static let money = Quantity(rawValue: "Money")
9+
/// }
10+
/// ```
11+
///
12+
/// Use a unique `rawValue` for each distinct dimension — equality and hashing are based on it.
13+
public struct Quantity: RawRepresentable, Hashable, Sendable {
14+
public let rawValue: String
15+
16+
public init(rawValue: String) {
17+
self.rawValue = rawValue
18+
}
419

520
// Base ISQ quantities: https://en.wikipedia.org/wiki/International_System_of_Quantities#Base_quantities
6-
case Amount
7-
case Current
8-
case Length
9-
case Mass
10-
case Temperature
11-
case Time
12-
case LuminousIntensity
21+
public static let Amount = Quantity(rawValue: "Amount")
22+
public static let Current = Quantity(rawValue: "Current")
23+
public static let Length = Quantity(rawValue: "Length")
24+
public static let Mass = Quantity(rawValue: "Mass")
25+
public static let Temperature = Quantity(rawValue: "Temperature")
26+
public static let Time = Quantity(rawValue: "Time")
27+
public static let LuminousIntensity = Quantity(rawValue: "LuminousIntensity")
1328

1429
// Extended SI Units: https://en.wikipedia.org/wiki/Non-SI_units_mentioned_in_the_SI
15-
case Angle
16-
case Data
30+
public static let Angle = Quantity(rawValue: "Angle")
31+
public static let Data = Quantity(rawValue: "Data")
1732
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import Units
2+
import XCTest
3+
4+
/// A user-defined dimension, declared outside the package. This is the capability
5+
/// that `Quantity` being `RawRepresentable` (rather than a closed enum) enables.
6+
private extension Quantity {
7+
static let money = Quantity(rawValue: "Money")
8+
}
9+
10+
class QuantityTests: XCTestCase {
11+
/// Built-in quantities keep their stable string raw values.
12+
func testBuiltInRawValues() {
13+
XCTAssertEqual(Quantity.Length.rawValue, "Length")
14+
XCTAssertEqual(Quantity.Time.rawValue, "Time")
15+
XCTAssertEqual(Quantity(rawValue: "Mass"), .Mass)
16+
}
17+
18+
/// A custom dimension is distinct from every built-in one and equal to itself.
19+
func testCustomQuantityIdentity() {
20+
XCTAssertEqual(Quantity.money, Quantity(rawValue: "Money"))
21+
XCTAssertNotEqual(Quantity.money, .Length)
22+
XCTAssertNotEqual(Quantity.money, .Amount)
23+
24+
// Usable as a dictionary key alongside built-ins.
25+
let dimension: [Quantity: Int] = [.money: 1, .Length: -3]
26+
XCTAssertEqual(dimension[.money], 1)
27+
XCTAssertEqual(dimension[.Length], -3)
28+
}
29+
30+
/// A unit on a custom dimension participates in dimensional arithmetic:
31+
/// a rate of `$/m^3` multiplied by a volume in `m^3` cancels to pure `$`.
32+
func testCustomDimensionCancelsInArithmetic() throws {
33+
let registry = try RegistryBuilder().addUnit(
34+
name: "dollar",
35+
symbol: "$",
36+
dimension: [.money: 1]
37+
).registry()
38+
39+
let dollar = try Unit(fromSymbol: "$", registry: registry)
40+
let cubicMeter = Unit.meter.pow(3)
41+
42+
let rate = Measurement(value: 180, unit: dollar / cubicMeter) // $180 per m^3
43+
let volume = Measurement(value: 24, unit: cubicMeter) // 24 m^3
44+
let cost = rate * volume
45+
46+
XCTAssertEqual(cost.value, 4320, accuracy: 1e-9)
47+
// The volume cancels, leaving a pure money dimension.
48+
XCTAssertEqual(cost.unit.dimension, [.money: 1])
49+
XCTAssertTrue(cost.isDimensionallyEquivalent(to: Measurement(value: 1, unit: dollar)))
50+
}
51+
52+
/// A custom dimension surfaces in the human-readable dimension description.
53+
func testCustomDimensionDescription() throws {
54+
let registry = try RegistryBuilder().addUnit(
55+
name: "dollar",
56+
symbol: "$",
57+
dimension: [.money: 1]
58+
).registry()
59+
60+
let dollar = try Unit(fromSymbol: "$", registry: registry)
61+
XCTAssertEqual(dollar.dimensionDescription(), "Money")
62+
}
63+
}

0 commit comments

Comments
 (0)