Swift Protocol-Oriented Programming — Protocols and Extensions

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: some preserves concrete type information for better performance, while any allows holding various concrete types for flexibility. Default to some and use any when heterogeneous collections are needed.

Was this article helpful?