Skip to content

1. Protocol-oriented design: some, any & dispatch

What & why

Protocols are Swift's main abstraction tool, but how you use a protocol type changes performance, flexibility, and what the compiler lets you do. This lesson covers the three ways to use a protocol (generics, some, any), how method dispatch works, type erasure, and the design heuristics that follow.

Three ways to say "anything that conforms to P"

protocol Shape {
    func area() -> Double
}
struct Circle: Shape { var r: Double; func area() -> Double { .pi * r * r } }
struct Square: Shape { var s: Double; func area() -> Double { s * s } }

// 1. Generic: the caller picks the concrete type; one type per call.
func describe<S: Shape>(_ shape: S) -> String { "area \(shape.area())" }

// 2. Opaque parameter: sugar for (1).
func describe2(_ shape: some Shape) -> String { "area \(shape.area())" }

// 3. Existential: a box that can hold *any* conforming type at runtime.
func describe3(_ shape: any Shape) -> String { "area \(shape.area())" }
<S: P> / some P (parameter) some P (return) any P
Concrete type known at compile time yes (per call) yes (by compiler, hidden from caller) no — decided at runtime
Can mix different types in a collection no no yes
Overhead none after specialization none box + dynamic dispatch (+ possible heap allocation)
Can use protocols with associated types / Self yes yes yes, with primary associated types or limited members

Rule of thumb: use some/generics by default; use any when you genuinely need heterogeneous storage or runtime flexibility ([any Shape], a stored property that changes type, plugin systems).

some as a return type

The function picks one concrete type and the caller can't see it:

func makeShape() -> some Shape { Circle(r: 1) }    // always Circle

// func makeShape(flag: Bool) -> some Shape {
//     flag ? Circle(r: 1) : Square(s: 1)           // ERROR: different types in branches
// }

If you need to choose among types at runtime, return any Shape (or use an enum, or @ViewBuilder-style result builders — lesson 3).

Opaque return types shine for chains of generic wrappers whose types are unspeakable:

func evenSquares(upTo n: Int) -> some Sequence<Int> {
    (1...n).lazy.filter { $0.isMultiple(of: 2) }.map { $0 * $0 }
}

Existentials in practice

let shapes: [any Shape] = [Circle(r: 1), Square(s: 2)]
let total = shapes.reduce(0) { $0 + $1.area() }

Opening an existential: when a method needs a generic parameter, Swift 5.7+ implicitly "opens" the box and calls the generic function with the dynamic type:

func printArea<S: Shape>(_ s: S) { print(s.area()) }
for shape in shapes { printArea(shape) }       // works: the existential is opened

Existentials with Self or associated-type requirements restrict what you can call. Use primary associated types to constrain them:

let numbers: any Sequence<Int> = [1, 2, 3]     // any Sequence whose Element == Int
for n in numbers { print(n) }

ExistentialAny

Writing a bare protocol name as a type ([Shape]) is legacy syntax. Enable the upcoming feature to make any mandatory, which makes the cost visible in code review:

.target(name: "MyLib", swiftSettings: [.enableUpcomingFeature("ExistentialAny")])

Static vs. dynamic dispatch

How does x.area() find the right code?

  1. Static dispatch — the compiler knows the exact function; it can inline it. Used for structs/enums, final classes, generic code that's specialized, and extension methods.
  2. Dynamic dispatch via a witness table — for a protocol-typed value (any P), the call goes through a table of function pointers for that conformance.
  3. Dynamic dispatch via a vtable — for class methods that can be overridden.

The visible consequence is the extension-method trap:

protocol Greeter { func hello() -> String }          // hello is a requirement

extension Greeter {
    func hello() -> String { "default hello" }       // default implementation
    func bye() -> String { "default bye" }           // NOT a requirement
}

struct Loud: Greeter {
    func hello() -> String { "HELLO" }
    func bye() -> String { "BYE" }
}

let g: any Greeter = Loud()
print(g.hello())     // HELLO        — requirement: dispatched dynamically
print(g.bye())       // default bye  — extension-only: dispatched statically on the declared type!
print(Loud().bye())  // BYE

Design rule: if a method is meant to be customised, declare it in the protocol. Put non-customisable helpers in extensions.

For classes, mark things final unless you intend subclassing — it enables static dispatch and documents intent. In library code, prefer public final class.

Type erasure

Sometimes you want one concrete type that hides a generic or protocol-based implementation — before any supported associated types, this was the only way. The standard library still ships AnySequence, AnyHashable, AnyIterator. Writing one by hand:

struct AnyCounter {
    private let _next: () -> Int
    init<C: Counter>(_ base: C) {
        var copy = base                 // each AnyCounter owns its own copy
        _next = { copy.next() }
    }
    func next() -> Int { _next() }
}

protocol Counter { mutating func next() -> Int }
struct Ticker: Counter { var n = 0; mutating func next() -> Int { n += 1; return n } }

let counters = [AnyCounter(Ticker()), AnyCounter(Ticker(n: 100))]

Today, prefer any Counter unless you need Equatable/Hashable behaviour on the erased type or must hide a generic parameter in a stored property.

Protocol design heuristics

  • Start with concrete types. Extract a protocol when you have two real conforming types or need a test double — not before.
  • Small protocols (Readable, Writable) compose with &.
  • Name by capability: -able, -ing, or a role (Delegate, DataSource).
  • Prefer composition over inheritance: a struct holding a any Logger beats a base class LoggingObject.
  • Provide default implementations in extensions so conformance is cheap.
  • Beware "protocol soup": if every type has a matching FooProtocol, you're abstracting too early.
  • Marker protocols (Sendable) express guarantees, not behaviours.

Dependency injection with protocols

protocol Logger { func log(_ message: String) }

struct ConsoleLogger: Logger { func log(_ message: String) { print(message) } }

struct Service<L: Logger> {          // generic: statically dispatched, test with a spy type
    let logger: L
    func run() { logger.log("running") }
}

struct DynamicService {              // existential: swap implementation at runtime
    var logger: any Logger
    func run() { logger.log("running") }
}

Use a generic when the dependency is fixed per instance and performance matters; use any when the implementation is chosen late (configuration, plugins, tests with many fakes).

Common mistakes

  • Using any everywhere and paying for boxing and dynamic dispatch unnecessarily.
  • Returning some P from a function with branches returning different types.
  • Expecting extension-only methods to be overridable through a protocol-typed variable.
  • Over-engineering with erasure wrappers where any P<…> works.
  • Non-final classes in performance-critical code without need.
  • Protocols with Self requirements used as plain types (Equatable can't be any Equatable in useful ways).

Exercises

  1. Write a Renderer protocol and use it three ways: generic, some, any. Look at the optimizer output difference with -O and -emit-assembly if you're curious.
  2. Reproduce the extension-method dispatch trap, then fix it by declaring the method in the protocol.
  3. Build a heterogeneous [any Plugin] list where each plugin has name and run().
  4. Write a function func evens() -> some Sequence<Int> and then change it to return any Sequence<Int> — what changes for callers?
  5. Implement AnyEquatableBox that wraps any Equatable & Hashable value and compares correctly (hint: AnyHashable).
  6. Make a Service<L: Logger> and test it with a SpyLogger that records messages.

Recap

  • Use generics / some by default; use any for heterogeneous or runtime-chosen values.
  • some return types hide a single concrete type; any boxes at runtime.
  • Declare customisable methods in the protocol; extension-only methods dispatch statically.
  • Primary associated types (any Sequence<Int>) make existentials practical.
  • Introduce protocols when you need them, and keep them small.

Next: Advanced generics →