Overview
Protocol-oriented programming in Swift separates capability from implementation. Protocols define what a type can do; structs, enums, and classes define how. This page covers protocol design, associated types, composition, and the any/some existential distinction introduced in Swift 5.7 and tightened in Swift 6. Read swift for the foundational rules, and swift-value-types for why value types pair well with protocols.
Define protocols around capabilities, not roles
A protocol should describe a single capability a caller depends on, not a complete role that a type plays. Small protocols compose into larger ones cleanly; large monolithic protocols force conforming types to implement irrelevant methods.
// Bad: one protocol doing too much
protocol DataSource {
func fetch() async throws -> [Item]
func save(_ item: Item) async throws
func delete(id: UUID) async throws
func count() -> Int
}
// Better: split by caller need
protocol ItemReader {
func fetch() async throws -> [Item]
func count() -> Int
}
protocol ItemWriter {
func save(_ item: Item) async throws
func delete(id: UUID) async throws
}A view model that only reads can depend on ItemReader alone. Tests substitute a simple stub that only implements the methods the subject under test actually calls.
Use protocol extensions for default implementations
Add behavior to a protocol via extensions so conforming types get it for free. Override the default in a specific conformer only when the type’s implementation is meaningfully different.
protocol Timestamped {
var createdAt: Date { get }
var updatedAt: Date { get }
}
extension Timestamped {
var age: TimeInterval { Date().timeIntervalSince(createdAt) }
var isStale: Bool { Date().timeIntervalSince(updatedAt) > 3600 }
}Protocol extensions dispatch statically, not dynamically. If a conforming class overrides isStale and you hold it as the protocol type any Timestamped, the override is not called. This is expected behavior, not a bug, but it is a reason to favor static dispatch in performance-sensitive paths.
Use associated types for type-safe generic protocols
An associated type makes a protocol generic over a conformer-supplied type. Use it when the protocol’s interface depends on a type that varies per conformance.
protocol Repository {
associatedtype Entity: Identifiable
func find(id: Entity.ID) async throws -> Entity?
func save(_ entity: Entity) async throws
func delete(id: Entity.ID) async throws
}
struct InMemoryRepository<E: Identifiable>: Repository {
typealias Entity = E
private var store: [E.ID: E] = [:]
func find(id: E.ID) -> E? { store[id] }
mutating func save(_ entity: E) { store[entity.id] = entity }
mutating func delete(id: E.ID) { store.removeValue(forKey: id) }
}Protocols with associated types cannot be used directly as existentials without wrapping in any. Prefer generic constraints when the concrete type is known at the call site; use any when the type must be erased.
Prefer some over any when the concrete type is statically known
some Protocol is an opaque type: the compiler knows the concrete type and dispatches statically. any Protocol is an existential box: the concrete type is hidden at runtime and dispatch goes through a witness table with overhead.
// some: caller does not know the type, but the compiler does; no boxing
func makeReader() -> some ItemReader {
InMemoryRepository<Item>()
}
// any: caller receives a type-erased value; boxing overhead
func register(reader: any ItemReader) {
self.reader = reader
}Return some from factory functions and computed properties. Accept any at injection boundaries where the concrete type genuinely varies at runtime, such as the environment injection pattern in swiftui.
Compose protocols for precise constraints
Combine multiple protocols with & to express a constraint that a type must satisfy several capabilities at once.
typealias ReadableStore = ItemReader & Sendable
typealias FullStore = ItemReader & ItemWriter & Sendable
func sync<S: FullStore>(store: S) async throws {
let local = try await store.fetch()
let remote = try await remoteClient.fetch()
let diff = computeDiff(local: local, remote: remote)
for item in diff.toSave { try await store.save(item) }
for id in diff.toDelete { try await store.delete(id: id) }
}Protocol composition is cleaner than a single large protocol and more expressive than a class hierarchy. It pairs naturally with swift-value-types: a struct can conform to multiple protocols without any inheritance.
Use conditional conformance for targeted behavior
Extend a generic type to conform to a protocol only when its type parameters meet a condition.
extension Array: Timestamped where Element: Timestamped {
var createdAt: Date { self.map(\.createdAt).min() ?? .distantPast }
var updatedAt: Date { self.map(\.updatedAt).max() ?? .distantPast }
}Conditional conformances express capability that emerges from the element type, not from the container. They are retroactive additions; use them judiciously to avoid surprising behavior.