Skip to content

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 T declares a type parameter pack.
  • repeat each value expands 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:

struct Wrapper<T: ~Copyable>: ~Copyable {
    var value: T
}

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 (Sequence rather than Array, Collection before RandomAccessCollection), but no less than you need.
  • Prefer protocol-constrained generics to Any.
  • Use some parameters 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 Any or AnyObject and 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.self vs T.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

  1. Write func firstDuplicate<S: Sequence>(in s: S) -> S.Element? where S.Element: Hashable.
  2. Build struct Meters/Feet phantom-typed Length<Unit> with + only between equal units and conversion functions.
  3. Create a typed ID<T> and a Repository<T: Identifiable> using it; prove at compile time that you can't look up an order by a user ID.
  4. Declare protocol Cache<Key, Value> with primary associated types and implement an LRU version.
  5. Use a parameter pack to write func firstValues<each C: Collection>(_ c: repeat each C) -> (repeat (each C).Element?).
  6. Implement a type-state builder RequestBuilder<HasURL> that only allows .build() once a URL has been set.

Recap

  • where clauses express relationships between generic parameters and associated types.
  • Primary associated types make any Collection<Int> and some 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.

Next: Property wrappers, result builders & key paths →