2. Advanced generics¶
What & why¶
Beyond Stack<Element>, Swift's generic system includes associated types with constraints, same-type requirements, conditional conformance, parameter packs for variadic generics, phantom types for compile-time safety, and noncopyable generics. These let you build APIs the compiler can verify instead of ones you have to document.
where clauses and same-type constraints¶
func merge<A: Sequence, B: Sequence>(_ a: A, _ b: B) -> [A.Element]
where A.Element == B.Element {
Array(a) + Array(b)
}
print(merge([1, 2], Set([3]))) // [1, 2, 3]
Constraints can relate associated types of different generics:
func sameOrder<C1: Collection, C2: Collection>(_ x: C1, _ y: C2) -> Bool
where C1.Element: Comparable, C1.Element == C2.Element {
x.elementsEqual(y)
}
Associated types, in depth¶
protocol Repository {
associatedtype Model: Identifiable
func find(id: Model.ID) -> Model?
mutating func save(_ model: Model)
}
struct User: Identifiable { let id: Int; var name: String }
struct InMemoryRepository<M: Identifiable>: Repository where M.ID: Hashable {
private var storage: [M.ID: M] = [:]
func find(id: M.ID) -> M? { storage[id] }
mutating func save(_ model: M) { storage[model.id] = model }
}
var users = InMemoryRepository<User>()
users.save(User(id: 1, name: "Ada"))
print(users.find(id: 1)?.name ?? "?")
Primary associated types¶
Declare which associated types are the "main" generic parameters so callers can write Repository<User>:
protocol Store<Value> {
associatedtype Value
func load() -> Value
}
struct Constant<V>: Store { let value: V; func load() -> V { value } }
func read(from store: some Store<Int>) -> Int { store.load() }
let anyStore: any Store<String> = Constant(value: "hi")
This is how Sequence<Int>, Collection<String>, and AsyncSequence<Data, Never> can be written.
Default associated types and constrained associated types¶
protocol Parser {
associatedtype Output = String // default
associatedtype Failure: Error = Never
func parse(_ text: String) throws(Failure) -> Output
}
Conditional conformance¶
A generic type conforms only when its parameters do:
struct Box<T> { var value: T }
extension Box: Equatable where T: Equatable {}
extension Box: Hashable where T: Hashable {}
extension Box: Comparable where T: Comparable {
static func < (a: Box, b: Box) -> Bool { a.value < b.value }
}
extension Box: Sendable where T: Sendable {}
print(Box(value: 1) < Box(value: 2)) // true
This is how Array<Int> is Equatable while Array<NotEquatable> isn't.
Phantom types: compile-time state¶
A generic parameter that appears in no stored property — only in the type — lets the compiler track state:
enum Validated {}
enum Unvalidated {}
struct Email<State> {
fileprivate(set) var raw: String
}
extension Email where State == Unvalidated {
init(_ raw: String) { self.raw = raw }
func validated() -> Email<Validated>? {
raw.contains("@") ? Email<Validated>(raw: raw) : nil
}
}
func sendWelcome(to email: Email<Validated>) { print("sending to \(email.raw)") }
if let ok = Email("ada@example.com").validated() {
sendWelcome(to: ok)
}
// sendWelcome(to: Email("raw")) // ERROR: Email<Unvalidated> is not Email<Validated>
Invalid states become unrepresentable at call sites — no runtime check needed. The same trick models units (Quantity<Meters>), IDs that can't be mixed up (ID<User> vs ID<Order>), and builder state machines.
struct ID<Entity>: Hashable { let raw: Int }
struct Order {}
let userID = ID<User>(raw: 1)
let orderID = ID<Order>(raw: 1)
// userID == orderID // ERROR: can't compare ID<User> with ID<Order>
Parameter packs (variadic generics)¶
Swift 5.9 added the ability to abstract over any number of type parameters:
func zipAll<each T>(_ value: repeat each T) -> (repeat each T) {
(repeat each value)
}
let t = zipAll(1, "two", 3.0) // (Int, String, Double)
each Tdeclares a type parameter pack.repeat each valueexpands a value pack element-by-element.
A practical example: apply a closure to each element of a tuple, requiring all to be CustomStringConvertible:
func describeAll<each T: CustomStringConvertible>(_ items: repeat each T) -> [String] {
var result: [String] = []
for item in repeat each items { // Swift 6: pack iteration
result.append(item.description)
}
return result
}
print(describeAll(1, true, "x")) // ["1", "true", "x"]
Packs replace the old pattern of writing overloads for 1, 2, 3, … 10 arguments (as in Combine's zip or SwiftUI's TupleView). Use them when you truly need arity-polymorphism; otherwise they are overkill.
Several packs in one signature must have the same length, which you state with a same-shape requirement, written where (repeat (each A, each B)): Any. That is an advanced corner; most code needs a single pack.
Noncopyable generics¶
Swift 6 lets generics and protocols work with ~Copyable types by suppressing the implicit Copyable requirement:
Most generic code stays copyable by default; noncopyable generics matter for resource-owning types (file handles, locks). See lesson 8.
Generic subscripts and @inlinable¶
extension Dictionary {
subscript<T>(key: Key, as type: T.Type) -> T? { self[key] as? T }
}
let config: [String: Any] = ["port": 8080]
let port = config["port", as: Int.self] // Optional(8080)
Library authors: generic code in a module is unspecialized for clients unless it is @inlinable (body visible to callers) or the compiler can see it. @inlinable is a source/ABI commitment — covered in lesson 11.
Designing generic APIs: heuristics¶
- Constrain as little as possible (
Sequencerather thanArray,CollectionbeforeRandomAccessCollection), but no less than you need. - Prefer protocol-constrained generics to
Any. - Use
someparameters for simple one-off constraints; named generics when parameters relate to each other. - Add constraints in extensions (
extension Foo where T: Bar) to keep the core type unconstrained. - Watch compile times: deep generic chains with literals and operators can make the type checker slow. Add type annotations at the root of complex expressions.
Common mistakes¶
- Using
AnyorAnyObjectand casting where a constraint would catch errors at compile time. - Over-constraining (
T: Hashable & Codable & Sendable) when the function uses only one capability. - Type-checker blow-ups from long mixed-literal expressions — break them up and annotate.
- Confusing
T.selfvsT.Type(the metatype value vs its type). - Trying to use packs with many differing logic paths — if every arity needs custom code, write separate functions.
- Forgetting that phantom parameters don't affect layout (they cost nothing at runtime — that's the point).
Exercises¶
- Write
func firstDuplicate<S: Sequence>(in s: S) -> S.Element? where S.Element: Hashable. - Build
struct Meters/Feetphantom-typedLength<Unit>with+only between equal units and conversion functions. - Create a typed
ID<T>and aRepository<T: Identifiable>using it; prove at compile time that you can't look up an order by a user ID. - Declare
protocol Cache<Key, Value>with primary associated types and implement an LRU version. - Use a parameter pack to write
func firstValues<each C: Collection>(_ c: repeat each C) -> (repeat (each C).Element?). - Implement a type-state builder
RequestBuilder<HasURL>that only allows.build()once a URL has been set.
Recap¶
whereclauses express relationships between generic parameters and associated types.- Primary associated types make
any Collection<Int>andsome Store<String>possible. - Conditional conformance lets wrappers inherit capabilities from their contents.
- Phantom types push invariants into the type system; parameter packs abstract over arity.
- Keep generic APIs minimal-constraint, and mind compile times.