How do phantom-typed identifiers stop an order id being passed where a user id is expected, and what do they cost?
Predict first
Two ids with the same raw number but different tags:
struct ID<Tag>: Hashable, Sendable, CustomStringConvertible {
let raw: Int
init(_ raw: Int) { self.raw = raw }
var description: String { "\(raw)" }
}
enum UserTag {}
enum OrderTag {}
typealias UserID = ID<UserTag>
typealias OrderID = ID<OrderTag>
func loadUser(_ id: UserID) -> String { "user \(id)" }
func loadOrder(_ id: OrderID) -> String { "order \(id)" }
let u = UserID(7), o = OrderID(7)
print(loadUser(u), "/", loadOrder(o))
print("same raw value, equal ids?", u.raw == o.raw)
print("size of an ID:", MemoryLayout<UserID>.size, "bytes, same as Int:", MemoryLayout<Int>.size)
let byUser: [UserID: String] = [u: "Asha"]
print(byUser[UserID(7)] ?? "none")
- user 7 / order 7 / same raw value, equal ids? true / size 8 bytes / Asha
- The program does not compile because the types differ
- The two ids compare equal as ID values
Predict first
Passing an order id to a function that takes a user id:
struct ID<Tag>: Hashable { let raw: Int }
enum UserTag {}
enum OrderTag {}
func loadUser(_ id: ID<UserTag>) -> String { "user \(id.raw)" }
let order = ID<OrderTag>(raw: 7)
print(loadUser(order))
- It does not compile
- It compiles and loads user 7
- It compiles with a warning
Mental model. Coloured wristbands at a festival: the numbers on them might be the same, but a red band does not open the green gate, and the guard does not need to read anything.
Most bugs of the form “loaded the wrong thing” come from passing an Int or String id to the wrong function: every id is the same type, so the compiler cannot help. A phantom type adds a type parameter that is never stored: struct ID<Tag>: Hashable { let raw: Int } with empty enums UserTag and OrderTag as tags. ID<UserTag> and ID<OrderTag> are different types, so loadUser(order) does not compile (the second check) while the two values can still hold the same number. At run time nothing changes: the wrapper is one Int (8 bytes in the check), equality and hashing work as before, and the ids still work as dictionary keys. Typealiases (typealias UserID = ID<UserTag>) keep call sites readable. Apple’s own APIs use the idea in places (the generic Identifiable pattern, KeyPath roots), and the open-source swift-tagged library packages it.
Costs and design points. Decoding needs a Codable conformance that forwards to the raw value (otherwise JSON gets a nested object); conforming to ExpressibleByIntegerLiteral is convenient for tests but lets any literal become an id, so use it only in test targets; the type parameter appears in every signature, so use it for ids and units that really get mixed up (ids, money in different currencies, meters versus feet), not for every string. For a handful of types a plain wrapper struct (struct UserID { let raw: Int }) is simpler and equally safe; the phantom form pays when you have many id types and want shared behaviour once. The same technique encodes state in the type (Request<Unsent> becomes Request<Sent>) so that calling send twice does not compile. Misuse resistance is one of the guidelines in Q359: this is its type-level form.
The tag exists only in the type system: same layout, same raw value, different types.
ID
Common traps
- Forgetting to forward
Codableto the raw value. The JSON shape changes silently. - Adding tag types to everything. Use them where mix-ups actually occur, otherwise you only add generics to read.
They might follow up with
- Is there a run-time cost? No: the tag is erased, the layout is the raw value (8 bytes in the check).
- When is a plain wrapper better? When you have only a few id types and no shared behaviour to write once.
Related questions
- 15.10.2 How do noncopyable types and consuming methods make an API hard to misuse: transactions, tokens and one-shot…
- 15.5.1 What makes a good Swift API: naming, argument labels, defaults, ownership, thread documentation and misuse…
- 15.4.1 How do you model errors in Swift: throws, Result, optionals and typed throws, and how do you translate them…
How do noncopyable types and consuming methods make an API hard to… →