Skip to content

Commit ed35e15

Browse files
Merge pull request #23 from DarkAndHungryGod/feat/extensible-quantity
Make Quantity extensible via RawRepresentable struct
2 parents 633d916 + fe4ba2b commit ed35e15

6 files changed

Lines changed: 387 additions & 225 deletions

File tree

README.md

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ let registryBuilder = RegistryBuilder()
128128
registryBuilder.addUnit(
129129
name: "centifoot",
130130
symbol: "cft",
131-
dimension: [.Length: 1],
131+
dimension: [.length: 1],
132132
coefficient: 0.003048 // This is the conversion to meters
133133
)
134134
let registry = registryBuilder.registry()
@@ -159,13 +159,13 @@ let registryBuilder = RegistryBuilder()
159159
try registryBuilder.addUnit(
160160
name: "apple",
161161
symbol: "apple",
162-
dimension: [.Amount: 1],
162+
dimension: [.amount: 1],
163163
coefficient: 1
164164
)
165165
try registryBuilder.addUnit(
166166
name: "carton",
167167
symbol: "carton",
168-
dimension: [.Amount: 1],
168+
dimension: [.amount: 1],
169169
coefficient: 48
170170
)
171171
let registry = registryBuilder.registry()
@@ -183,7 +183,7 @@ We can extend this example to determine how many cartons a group of people can p
183183
try registryBuilder.addUnit(
184184
name: "person",
185185
symbol: "person",
186-
dimension: [.Amount: 1],
186+
dimension: [.amount: 1],
187187
coefficient: 1
188188
)
189189
let person = try Unit(fromSymbol: "person", registry: registryBuilder.registry())
@@ -194,6 +194,46 @@ 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: "Acme.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+
A dedicated dimension is safer than repurposing an unrelated base quantity such as `.amount`,
227+
which would silently cancel against unrelated units that share it. But the `rawValue` namespace
228+
is **global**: a `Quantity` is identified solely by its raw string, across every module linked
229+
into the program. If two modules each define a dimension with the same raw value — say both use
230+
`"Money"` — they are treated as *the same dimension* and will silently cancel against each other,
231+
producing wrong results with no error. There is no central registry to detect the clash.
232+
233+
To avoid this, namespace every custom `rawValue` with a prefix unique to your module or
234+
organization (e.g. `"Acme.Money"`, not `"Money"`), exactly as the example above does. Reserve
235+
bare names like `"Money"` for nothing, since you cannot know what another dependency has chosen.
236+
197237
## CLI
198238

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

Sources/Units/Quantity.swift

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,70 @@
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+
/// Equality and hashing are based on `rawValue`, so each distinct dimension needs a unique
13+
/// raw string. Because the namespace is global across every module that links this package,
14+
/// two modules that independently pick the same raw value are treated as the *same* dimension
15+
/// and silently cancel against one another. Prefix custom raw values to avoid collisions —
16+
/// e.g. `"Acme.Money"` rather than `"Money"`.
17+
public struct Quantity: RawRepresentable, Hashable, Sendable {
18+
public let rawValue: String
19+
20+
public init(rawValue: String) {
21+
self.rawValue = rawValue
22+
}
423

524
// 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
25+
public static let amount = Quantity(rawValue: "Amount")
26+
public static let current = Quantity(rawValue: "Current")
27+
public static let length = Quantity(rawValue: "Length")
28+
public static let mass = Quantity(rawValue: "Mass")
29+
public static let temperature = Quantity(rawValue: "Temperature")
30+
public static let time = Quantity(rawValue: "Time")
31+
public static let luminousIntensity = Quantity(rawValue: "LuminousIntensity")
1332

1433
// Extended SI Units: https://en.wikipedia.org/wiki/Non-SI_units_mentioned_in_the_SI
15-
case Angle
16-
case Data
34+
public static let angle = Quantity(rawValue: "Angle")
35+
public static let data = Quantity(rawValue: "Data")
36+
}
37+
38+
// MARK: - Deprecated PascalCase aliases
39+
40+
// The previous `enum Quantity` spelled its cases in PascalCase. These aliases keep existing
41+
// call sites compiling against the renamed lowerCamelCase properties (Swift API Design
42+
// Guidelines) and can be removed in a future major release.
43+
public extension Quantity {
44+
@available(*, deprecated, renamed: "amount")
45+
static let Amount = Quantity.amount
46+
@available(*, deprecated, renamed: "current")
47+
static let Current = Quantity.current
48+
@available(*, deprecated, renamed: "length")
49+
static let Length = Quantity.length
50+
@available(*, deprecated, renamed: "mass")
51+
static let Mass = Quantity.mass
52+
@available(*, deprecated, renamed: "temperature")
53+
static let Temperature = Quantity.temperature
54+
@available(*, deprecated, renamed: "time")
55+
static let Time = Quantity.time
56+
@available(*, deprecated, renamed: "luminousIntensity")
57+
static let LuminousIntensity = Quantity.luminousIntensity
58+
@available(*, deprecated, renamed: "angle")
59+
static let Angle = Quantity.angle
60+
@available(*, deprecated, renamed: "data")
61+
static let Data = Quantity.data
62+
}
63+
64+
// MARK: - CustomStringConvertible
65+
66+
extension Quantity: CustomStringConvertible {
67+
// Preserve the enum's behavior: interpolating a Quantity yields its raw value
68+
// (e.g. "Length"), not the synthesized struct description.
69+
public var description: String { rawValue }
1770
}

0 commit comments

Comments
 (0)