What Is Protocol-Oriented Programming?
Swift favors Protocol-Oriented Programming (POP) over class-inheritance-based OOP. Protocols define the interface that types must follow, and protocol extensions provide default implementations. Since they can also be applied to structs and enums, you can avoid reference type issues like unintended shared state.
This article covers protocol basics through associated types and protocol composition with practical patterns.
Protocol Basics
Protocols define a blueprint of properties and methods. When a type adopts (conforms to) a protocol, it must implement the required members.
import Foundation
// Protocol definition
protocol Describable {
var description: String { get } // Read-only property
func summarize() -> String // Method
}
protocol Identifiable {
var id: String { get }
}
// Struct conforming to protocols
struct User: Describable, Identifiable {
let id: String
let name: String
let email: String
var description: String {
return "\(name) (\(email))"
}
func summarize() -> String {
return "User \(id): \(name)"
}
}
struct Product: Describable, Identifiable {
let id: String
let name: String
let price: Int
var description: String {
return "\(name) — $\(price)"
}
func summarize() -> String {
return "Product \(id): \(name) ($\(price))"
}
}
// Polymorphism using protocol types
func printSummaries(_ items: [Describable]) {
for item in items {
print(item.summarize())
}
}
let user = User(id: "U001", name: "John Doe", email: "john@example.com")
let product = Product(id: "P001", name: "Keyboard", price: 89)
printSummaries([user, product])
// Output:
// User U001: John Doe
// Product P001: Keyboard ($89)
Protocol Extensions and Default Implementations
Protocol extensions provide default implementations so that conforming types can use them without implementing separately.
import Foundation
// Protocol definition
protocol Loggable {
var logPrefix: String { get }
}
// Protocol extension — Providing default implementation
extension Loggable {
var logPrefix: String {
return String(describing: type(of: self))
}
func log(_ message: String) {
let timestamp = ISO8601DateFormatter().string(from: Date())
print("[\(timestamp)] [\(logPrefix)] \(message)")
}
func logError(_ message: String) {
log("ERROR: \(message)")
}
}
// Extending Comparable with range clamping
extension Comparable {
func clamped(to range: ClosedRange<Self>) -> Self {
return min(max(self, range.lowerBound), range.upperBound)
}
}
// Collection extension — Safe index access
extension Collection {
subscript(safe index: Index) -> Element? {
return indices.contains(index) ? self[index] : nil
}
}
// Protocol adoption — Default implementations automatically available
struct OrderService: Loggable {
func processOrder(id: String) {
log("Order \(id) processing started")
// Business logic...
log("Order \(id) processing completed")
}
}
struct PaymentService: Loggable {
let logPrefix = "Payment" // Override default implementation
func charge(amount: Int) {
log("Payment of $\(amount) requested")
}
}
let orderService = OrderService()
orderService.processOrder(id: "ORD-001")
// Output:
// [2026-02-22T15:00:00Z] [OrderService] Order ORD-001 processing started
// [2026-02-22T15:00:00Z] [OrderService] Order ORD-001 processing completed
let paymentService = PaymentService()
paymentService.charge(amount: 50000)
// Output:
// [2026-02-22T15:00:00Z] [Payment] Payment of $50000 requested
// Using the Comparable extension
let temperature = 35
let safe = temperature.clamped(to: 0...30)
print("Original: \(temperature), Clamped: \(safe)")
// Output: Original: 35, Clamped: 30
// Safe index access
let items = ["A", "B", "C"]
print("items[1]: \(items[safe: 1] ?? "none")") // B
print("items[5]: \(items[safe: 5] ?? "none")") // none
// Output:
// items[1]: B
// items[5]: none
Associated Types
Associated types abstract the types used within a protocol. Combined with generics, they define type-safe interfaces.
import Foundation
// Protocol with associated types
protocol Repository {
associatedtype Entity // Concrete type determined at implementation
associatedtype ID: Hashable
func findById(_ id: ID) -> Entity?
func findAll() -> [Entity]
func save(_ entity: Entity) -> Entity
func deleteById(_ id: ID) -> Bool
}
// Concrete implementation
struct Article {
let id: Int
var title: String
var content: String
}
class ArticleRepository: Repository {
// Entity = Article, ID = Int determined here
private var storage: [Int: Article] = [:]
func findById(_ id: Int) -> Article? {
return storage[id]
}
func findAll() -> [Article] {
return Array(storage.values)
}
func save(_ entity: Article) -> Article {
storage[entity.id] = entity
return entity
}
func deleteById(_ id: Int) -> Bool {
return storage.removeValue(forKey: id) != nil
}
}
// Using protocol in generic function
func printAllEntities<R: Repository>(from repo: R) where R.Entity: CustomStringConvertible {
let all = repo.findAll()
for entity in all {
print(" - \(entity)")
}
}
// Extend Article to conform to CustomStringConvertible
extension Article: CustomStringConvertible {
var description: String {
return "[\(id)] \(title)"
}
}
let repo = ArticleRepository()
let _ = repo.save(Article(id: 1, title: "Swift POP Guide", content: "..."))
let _ = repo.save(Article(id: 2, title: "Getting Started with SwiftUI", content: "..."))
if let article = repo.findById(1) {
print("Found article: \(article)")
}
// Output: Found article: [1] Swift POP Guide
print("All articles:")
printAllEntities(from: repo)
// Output:
// All articles:
// - [1] Swift POP Guide
// - [2] Getting Started with SwiftUI
Protocol Composition and some/any
Multiple protocols can be combined to express type constraints. Since Swift 5.7, the some and any keywords distinguish between existential types and opaque types.
import Foundation
protocol Cacheable {
var cacheKey: String { get }
var ttl: Int { get } // In seconds
}
protocol Serializable {
func toJSON() -> String
}
// Protocol composition — Conforming to multiple protocols simultaneously
struct CachedUser: Cacheable, Serializable, CustomStringConvertible {
let id: String
let name: String
var cacheKey: String { "user:\(id)" }
var ttl: Int { 3600 }
var description: String { "\(name) (cache: \(cacheKey))" }
func toJSON() -> String {
return "{\"id\": \"\(id)\", \"name\": \"\(name)\"}"
}
}
// Protocol composition type as parameter
func cacheAndSerialize(_ item: any Cacheable & Serializable) {
print("Cache key: \(item.cacheKey), TTL: \(item.ttl)s")
print("JSON: \(item.toJSON())")
}
// some — Opaque return type (hides concrete type, preserves type info)
func createDefaultUser() -> some Cacheable & Serializable {
return CachedUser(id: "default", name: "Default User")
}
let user = CachedUser(id: "U001", name: "John Doe")
cacheAndSerialize(user)
// Output:
// Cache key: user:U001, TTL: 3600s
// JSON: {"id": "U001", "name": "John Doe"}
let defaultUser = createDefaultUser()
print("Default user cache key: \(defaultUser.cacheKey)")
// Output: Default user cache key: user:default
Practical Tips
- Prefer structs + protocols: Use structs with protocol conformance over class inheritance as the default. Value types prevent unintended state sharing.
- Default implementations via protocol extensions: Provide default implementations in protocol extensions when common logic applies to all conforming types.
- Small protocols: Define multiple small protocols and compose them rather than one large protocol (Interface Segregation Principle).
- Associated types as generic alternatives: Use associated types to abstract types within protocols.
- some vs any:
somepreserves concrete type information for better performance, whileanyallows holding various concrete types for flexibility. Default tosomeand useanywhen heterogeneous collections are needed.