Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 44 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ let registryBuilder = RegistryBuilder()
registryBuilder.addUnit(
name: "centifoot",
symbol: "cft",
dimension: [.Length: 1],
dimension: [.length: 1],
coefficient: 0.003048 // This is the conversion to meters
)
let registry = registryBuilder.registry()
Expand Down Expand Up @@ -159,13 +159,13 @@ let registryBuilder = RegistryBuilder()
try registryBuilder.addUnit(
name: "apple",
symbol: "apple",
dimension: [.Amount: 1],
dimension: [.amount: 1],
coefficient: 1
)
try registryBuilder.addUnit(
name: "carton",
symbol: "carton",
dimension: [.Amount: 1],
dimension: [.amount: 1],
coefficient: 48
)
let registry = registryBuilder.registry()
Expand All @@ -183,7 +183,7 @@ We can extend this example to determine how many cartons a group of people can p
try registryBuilder.addUnit(
name: "person",
symbol: "person",
dimension: [.Amount: 1],
dimension: [.amount: 1],
coefficient: 1
)
let person = try Unit(fromSymbol: "person", registry: registryBuilder.registry())
Expand All @@ -194,6 +194,46 @@ let weeklyCartons = try (workforce * personPickRate).convert(to: carton / .week)
print(weeklyCartons) // Prints '350.0 carton/week'
```

### Custom Dimensions

The built-in `Quantity` dimensions (`.length`, `.mass`, `.time`, etc.) cover the ISQ base
quantities, but you can define your own dimension when none of them fit — for example, a
`Money` dimension for cost calculations. Because `Quantity` is `RawRepresentable`, you can
add one with a static extension:

```swift
extension Quantity {
static let money = Quantity(rawValue: "Acme.Money")
}
```

Your dimension then behaves like any built-in one. Here a concrete rate of `$/m^3` is
multiplied by a volume in `m^3`, and the volume cancels to leave a pure cost:

```swift
let registry = try RegistryBuilder().addUnit(
name: "dollar",
symbol: "$",
dimension: [.money: 1]
).registry()
let dollar = try Unit(fromSymbol: "$", registry: registry)

let rate = Measurement(value: 180, unit: dollar / .meter.pow(3)) // $180 per m^3
let volume = Measurement(value: 24, unit: .meter.pow(3)) // 24 m^3
print(rate * volume) // Prints '4320.0 $'
```

A dedicated dimension is safer than repurposing an unrelated base quantity such as `.amount`,
which would silently cancel against unrelated units that share it. But the `rawValue` namespace
is **global**: a `Quantity` is identified solely by its raw string, across every module linked
into the program. If two modules each define a dimension with the same raw value — say both use
`"Money"` — they are treated as *the same dimension* and will silently cancel against each other,
producing wrong results with no error. There is no central registry to detect the clash.

To avoid this, namespace every custom `rawValue` with a prefix unique to your module or
organization (e.g. `"Acme.Money"`, not `"Money"`), exactly as the example above does. Reserve
bare names like `"Money"` for nothing, since you cannot know what another dependency has chosen.

## CLI

The easiest way to install the CLI is with brew:
Expand Down
77 changes: 65 additions & 12 deletions Sources/Units/Quantity.swift
Original file line number Diff line number Diff line change
@@ -1,17 +1,70 @@
/// A dimension of measurement. These may be combined to form composite dimensions and measurements
public enum Quantity: String, Sendable {
// TODO: Consider changing away from enum for extensibility
/// A dimension of measurement. These may be combined to form composite dimensions and measurements.
///
/// `Quantity` is `RawRepresentable` rather than an enum so that callers can define their own
/// dimensions without modifying this package. Add one with a static extension:
///
/// ```swift
/// extension Quantity {
/// static let money = Quantity(rawValue: "Money")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing CustomStringConvertible — this is a silent behavioral regression.

With the enum, "\(Quantity.Length)" (and String(describing:)) printed "Length". With the struct it will now print "Quantity(rawValue: \"Length\")" — the default struct description. Any code that logs or displays a Quantity value (or a [Quantity: Int] dictionary) will silently change its output.

Please add:

extension Quantity: CustomStringConvertible {
    public var description: String { rawValue }
}

This restores the prior behaviour and is consistent with how Notification.Name prints its string value.


Generated by Claude Code

/// }
/// ```
///
/// Equality and hashing are based on `rawValue`, so each distinct dimension needs a unique
/// raw string. Because the namespace is global across every module that links this package,
/// two modules that independently pick the same raw value are treated as the *same* dimension
/// and silently cancel against one another. Prefix custom raw values to avoid collisions —
/// e.g. `"Acme.Money"` rather than `"Money"`.
public struct Quantity: RawRepresentable, Hashable, Sendable {
public let rawValue: String

public init(rawValue: String) {
self.rawValue = rawValue
}

// Base ISQ quantities: https://en.wikipedia.org/wiki/International_System_of_Quantities#Base_quantities
case Amount
case Current
case Length
case Mass
case Temperature
case Time
case LuminousIntensity
public static let amount = Quantity(rawValue: "Amount")
public static let current = Quantity(rawValue: "Current")
public static let length = Quantity(rawValue: "Length")
public static let mass = Quantity(rawValue: "Mass")
public static let temperature = Quantity(rawValue: "Temperature")
public static let time = Quantity(rawValue: "Time")
public static let luminousIntensity = Quantity(rawValue: "LuminousIntensity")

// Extended SI Units: https://en.wikipedia.org/wiki/Non-SI_units_mentioned_in_the_SI
case Angle
case Data
public static let angle = Quantity(rawValue: "Angle")
public static let data = Quantity(rawValue: "Data")
}

// MARK: - Deprecated PascalCase aliases

// The previous `enum Quantity` spelled its cases in PascalCase. These aliases keep existing
// call sites compiling against the renamed lowerCamelCase properties (Swift API Design
// Guidelines) and can be removed in a future major release.
public extension Quantity {
@available(*, deprecated, renamed: "amount")
static let Amount = Quantity.amount
@available(*, deprecated, renamed: "current")
static let Current = Quantity.current
@available(*, deprecated, renamed: "length")
static let Length = Quantity.length
@available(*, deprecated, renamed: "mass")
static let Mass = Quantity.mass
@available(*, deprecated, renamed: "temperature")
static let Temperature = Quantity.temperature
@available(*, deprecated, renamed: "time")
static let Time = Quantity.time
@available(*, deprecated, renamed: "luminousIntensity")
static let LuminousIntensity = Quantity.luminousIntensity
@available(*, deprecated, renamed: "angle")
static let Angle = Quantity.angle
@available(*, deprecated, renamed: "data")
static let Data = Quantity.data
}

// MARK: - CustomStringConvertible

extension Quantity: CustomStringConvertible {
// Preserve the enum's behavior: interpolating a Quantity yields its raw value
// (e.g. "Length"), not the synthesized struct description.
public var description: String { rawValue }
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swift API Design Guidelines: static properties should be lowerCamelCase.

public static let Length, public static let Time, etc. should be public static let length, public static let time, … These were enum cases before (where PascalCase is conventional), but as struct static properties the guideline is clear: lowerCamelCase.

This is a source-breaking change on existing call sites (.Length.length), but you're already accepting a minor version bump for the failable-init change, so this is the right moment to fix it. If you'd rather defer, please at least add a deprecation shim:

@available(*, deprecated, renamed: "length")
public static let Length = Quantity(rawValue: "Length")
public static let length = Quantity(rawValue: "Length")

Either way, leaving public static properties in PascalCase in a Swift library is a lint error and will generate warnings for callers on newer toolchains.


Generated by Claude Code

Loading
Loading