← All topics/Swift language features

Question 3

Protocols, generics, and where clauses

How do you use protocol-oriented design with generics and associated types? When do you need a where clause on an extension or generic method?

Answer outline

Protocols describe what a type can do. Generics let one piece of code work with any type that fits. An associated type is a placeholder in a protocol that each conformer fills in with its own type.

Use an associated type when a protocol needs a relationship between types rather than a single fixed type. Typical cases are a store tied to one model type or a coordinator that produces one specific output type.

A where clause adds extra constraints to generic code. The extension or method then exists only for types that meet them, such as an associated type that conforms to Equatable, Hashable, or Sendable.

Principles

  • Protocols describe a role, so name them for the capability they promise.
  • Generics keep one implementation reusable and type-safe without casting at the call site.
  • Use an associated type when each conformer picks its own related type, such as a cache's value type.
  • Put a where clause on an extension to add behavior only for types that can support it.
  • Prefer compile-time constraints over runtime type checks, and keep abstractions no wider than the code needs.

Every Cache works with some Value, but each conforming type chooses its own value type:

Protocol with associated type
protocol Cache {
    associatedtype Value

    func save(_ value: Value, for key: String)
    func load(for key: String) -> Value?
}

One implementation serves any value type:

Generic type conforming to the protocol
final class MemoryCache<T>: Cache {
    private var storage: [String: T] = [:]

    func save(_ value: T, for key: String) {
        storage[key] = value
    }

    func load(for key: String) -> T? {
        storage[key]
    }
}

This extension uses ==, so it only makes sense when Value is Equatable, and the where clause says so:

Where clause on an extension
extension Cache where Value: Equatable {
    func contains(_ value: Value, for key: String) -> Bool {
        load(for: key) == value
    }
}

Both forms require T to conform to Equatable, so <T: Equatable> and a trailing where clause are equivalent here:

Where clause on a generic method
func areEqual<T: Equatable>(_ lhs: T, _ rhs: T) -> Bool {
    lhs == rhs
}

func compare<T>(_ lhs: T, _ rhs: T) -> Bool where T: Equatable {
    lhs == rhs
}

This extension exists only when the repository's Model is Sendable:

Constraining an associated type
protocol Repository {
    associatedtype Model
    func save(_ model: Model)
}

extension Repository where Model: Sendable {
    func saveSafely(_ model: Model) {
        save(model)
    }
}