Swift & iOS

Learn how Swift protocols, generics, associated types, existentials, opaque types, and type aliases affect API design.

~31 min reading
Suggest content

Swift Types, Protocols, and Generics

🟪 Syntax · 🟦 Types/APIs · 🟩 Correct pattern · 🟥 Bug · 🟨 Gotcha · 🧠 Explanation

Quick Recap — The short mental model

  • A type defines what values are and what operations they support.
  • A protocol describes capabilities a type promises to provide.
  • A generic parameter keeps a concrete type relationship visible to the compiler.
  • An associated type names a type that each protocol conformance chooses.
  • some P hides one fixed concrete type behind protocol P.
  • any P stores a value whose concrete type can vary at runtime.
  • A type alias gives an existing type another name; it does not create a new type.

Quick chooser

GoalPrefer
Store a replaceable dependencyany Protocol
Preserve a type relationship across an operationA generic parameter such as T: Protocol
Hide one fixed return implementationsome Protocol
Store mixed conforming values[any Protocol]
Let each conformer select a related typeassociatedtype
Constrain the important associated type at the use siteA primary associated type
Create a genuinely distinct domain typeA wrapper struct, not typealias

BVC example: declaration, construction, let, and var

In Swift, a protocol is the abstraction: it names the behavior a value must provide. It is not a concrete class and has no constructor of its own. A concrete struct, class, or enum adopts the protocol; construct that conforming type.

SWIFT
protocol BVC {
    func displayTitle() -> String
}
​
struct HomeBVC: BVC {
    func displayTitle() -> String { "Home" }
}
​
struct SettingsBVC: BVC {
    func displayTitle() -> String { "Settings" }
}

🟥 This cannot work because BVC describes requirements but does not define a concrete value or initializer:

SWIFT
let value = BVC() // Error: a protocol cannot be instantiated.

🟩 Create the conformer on the right, then choose whether the left-hand variable keeps the concrete type or uses the protocol abstraction:

SWIFT
let concrete = HomeBVC()                 // Type is HomeBVC.
let fixed: any BVC = HomeBVC()            // Protocol-typed binding; cannot be reassigned.
var replaceable: any BVC = HomeBVC()      // Can store any BVC conformer.
​
print(fixed.displayTitle())               // "Home"
replaceable = SettingsBVC()               // Replace the stored conformer.
print(replaceable.displayTitle())         // "Settings"
DeclarationWhat can change?
let concrete = HomeBVC()Neither the binding nor its concrete type can be replaced.
let fixed: any BVC = HomeBVC()The binding cannot be reassigned; calls to protocol requirements still work.
var replaceable: any BVC = HomeBVC()The stored value can be replaced with another conforming type.
var concrete = HomeBVC()The value can change, but only to another HomeBVC, not SettingsBVC.

🟨 var on an existential means the stored conformer can be replaced. It does not automatically make a struct's properties mutable. A let class reference can still call methods that mutate the referenced object; let fixes the reference binding, not the class object's internal state.

A protocol with no requirements

An empty protocol is allowed. It can be useful as a marker or capability, but it exposes no methods to call. You still instantiate a conformer:

SWIFT
protocol BVC {}
​
struct HomeBVC: BVC {}
let screen: any BVC = HomeBVC()

Passing a protocol value into a function

Use an existential when the function only needs the protocol's behavior and accepts different implementations at runtime:

SWIFT
func title(for screen: any BVC) -> String {
    screen.displayTitle()
}
​
let homeTitle = title(for: HomeBVC())
let settingsTitle = title(for: SettingsBVC())

Use a generic when the operation needs to preserve which concrete conformer it received:

SWIFT
func keepSameType<T: BVC>(_ screen: T) -> T {
    screen
}
​
let home: HomeBVC = keepSameType(HomeBVC())

🧠 Interview wording: “The protocol is the abstraction, and HomeBVC is the concrete type. I cannot construct BVC(); I construct HomeBVC() and store it as any BVC when I want to depend on the interface.”

Practical iOS protocol examples

These examples show where protocols improve an iOS design. The authoritative explanation of dependency inversion and injection lives in the architecture guide; the first example here focuses on the protocol contract and test substitution.

Example 1: dependency boundary and test substitution

The screen needs user data; it does not need to know whether that data came from HTTP, a database, or a preview stub.

SWIFT
import Foundation
​
struct User: Sendable {
    let id: UUID
    let name: String
}
​
protocol UserLoading: Sendable {
    func loadUser(id: UUID) async throws -> User
}
​
struct PreviewUserLoader: UserLoading {
    func loadUser(id: UUID) async throws -> User {
        User(id: id, name: "Preview User")
    }
}
​
@MainActor
final class UserDetailModel {
    private let loader: any UserLoading
    private(set) var user: User?
​
    init(loader: any UserLoading) {
        self.loader = loader
    }
​
    func load(id: UUID) async throws {
        user = try await loader.loadUser(id: id)
    }
}

🟩 This is DIP in practice: the high-level model states the capability it needs, and the composition root selects the concrete loader.

Example 2: interface segregation for read and write clients

A read-only feed should not depend on save and delete operations.

SWIFT
struct Article: Sendable {
    let id: Int
    let title: String
}
​
protocol ArticleReading: Sendable {
    func articles() async throws -> [Article]
}
​
protocol ArticleWriting: Sendable {
    func save(_ article: Article) async throws
}
​
struct FeedService {
    private let reader: any ArticleReading
​
    init(reader: any ArticleReading) {
        self.reader = reader
    }
​
    func load() async throws -> [Article] {
        try await reader.articles()
    }
}

🟩 Small client-specific protocols reduce fake methods, unclear contracts, and unnecessary coupling.

Example 3: strategy for behavior that varies

The checkout stays stable while pricing rules can change or be selected at runtime.

SWIFT
protocol PricingRule: Sendable {
    func finalPrice(for subtotalInCents: Int) -> Int
}
​
struct StandardPricing: PricingRule {
    func finalPrice(for subtotalInCents: Int) -> Int {
        subtotalInCents
    }
}
​
struct PercentageDiscount: PricingRule {
    let percentage: Int
​
    func finalPrice(for subtotalInCents: Int) -> Int {
        subtotalInCents * (100 - percentage) / 100
    }
}
​
struct CheckoutCalculator {
    let rule: any PricingRule
​
    func total(for subtotalInCents: Int) -> Int {
        rule.finalPrice(for: subtotalInCents)
    }
}

🟩 This supports OCP only when every rule preserves the contract—for example, how rounding and negative totals are handled. That behavioral promise is the LSP part.

Example 4: class-bound delegate and weak ownership

Delegates normally represent one owner or listener. Class-bound conformance allows weak storage and prevents a retain cycle.

SWIFT
@MainActor
protocol LoginCoordinatorDelegate: AnyObject {
    func loginCoordinatorDidFinish(userID: String)
}
​
@MainActor
final class LoginCoordinator {
    weak var delegate: (any LoginCoordinatorDelegate)?
​
    func finish(userID: String) {
        delegate?.loginCoordinatorDidFinish(userID: userID)
    }
}

Example 5: generic algorithm when the relationship matters

The mapper chooses its input and output types. The generic function preserves both types for the whole operation.

SWIFT
protocol Mapper {
    associatedtype Input
    associatedtype Output
​
    func map(_ input: Input) throws -> Output
}
​
func mapAll<M: Mapper>(
    _ inputs: [M.Input],
    using mapper: M
) throws -> [M.Output] {
    try inputs.map(mapper.map)
}

Using any Mapper here would erase the Input and Output relationship that the function needs. A generic is the clearer tool.

Example 6: when a concrete type is better

SWIFT
struct UserNameFormatter {
    func displayName(first: String, last: String) -> String {
        "\(first) \(last)"
    }
}

If there is one deterministic implementation and no meaningful substitution boundary, a concrete value is simpler. Introduce a protocol later if requirements create a real variation point.

Protocol requirements and conformance

Protocols declare requirements. A conforming type supplies the required implementation. A default implementation in a protocol extension can satisfy a requirement, but use defaults carefully: they should be predictable and should not hide missing business behavior.

SWIFT
protocol Identified {
    associatedtype ID: Hashable
    var id: ID { get }
}
​
struct Article: Identified {
    let id: UUID
    let title: String
}

The compiler infers Article.ID to be UUID. The type can also state it explicitly with typealias ID = UUID.

🟥 A protocol is not a value you can construct

A protocol is a contract, not a concrete type with a constructor. This does not create an Abs value:

SWIFT
protocol Abs {}
​
let value = Abs()       // Error: a protocol can't be instantiated
var otherValue = Abs()  // Same error
let: Abs = Abs()        // Invalid declaration syntax

🟩 Declare a concrete type that conforms, then create that type:

SWIFT
protocol Abs {}
​
struct ConcreteAbs: Abs {}
struct AnotherAbs: Abs {}
​
let fixed: any Abs = ConcreteAbs()
var replaceable: any Abs = ConcreteAbs()
replaceable = AnotherAbs()

Read let fixed: any Abs = ConcreteAbs() from left to right:

  1. let declares a constant binding.
  2. fixed is the variable name.
  3. : any Abs is the declared type: an existential value that can hold a concrete Abs conformer.
  4. = ConcreteAbs() creates and assigns the concrete value.

In modern Swift, write any Abs when a protocol is used as an existential type. The protocol still doesn't supply the constructor; ConcreteAbs() does.

let versus var

SWIFT
let title = "Swift"      // Can't reassign the binding.
var page = 1              // Can reassign the binding.
page += 1

For a value type, a let instance is immutable, including its var properties. For a class instance, let prevents replacing the reference, but the referenced object's var properties can still change:

SWIFT
struct SettingsValue { var theme = "light" }
let settingsValue = SettingsValue()
// settingsValue.theme = "dark" // Error: value is immutable
​
final class SettingsObject { var theme = "light" }
let settingsObject = SettingsObject()
settingsObject.theme = "dark" // Allowed: the reference is fixed; the object is mutable

Use let by default. Choose var when the binding itself must change. If two examples declare the same name in one scope, only one declaration is allowed; separate code examples are separate scopes.

Requirements you can put in a protocol

SWIFT
protocol Cache {
    associatedtype Key: Hashable
    associatedtype Value
​
    var count: Int { get }
    subscript(key: Key) -> Value? { get set }
    mutating func removeAll()
    static func makeDefault() -> Self
}
  • A property requirement describes access (get, or get set), not storage. A conformer can use stored or computed properties.
  • A mutating requirement lets a value type change itself. A class implementation does not need the mutating modifier.
  • Self refers to the conforming type. It is useful for fluent APIs and factories, but it can limit how a protocol is used as an existential value.
  • Protocols can inherit other protocols and can be composed with &, for example any Codable & Sendable.

associatedtype: a type selected by the conformer

Use an associated type when the protocol's operations are related to a type that each conformer chooses.

SWIFT
protocol Repository {
    associatedtype Entity: Identifiable
    associatedtype EntityID: Hashable
        where Entity.ID == EntityID
​
    func entity(for id: EntityID) async throws -> Entity
}
​
struct User: Identifiable, Sendable {
    let id: UUID
    let name: String
}
​
struct UserRepository: Repository {
    func entity(for id: UUID) async throws -> User {
        User(id: id, name: "Aylin")
    }
}

Here the compiler infers Entity == User and EntityID == UUID. Associated types are not generic parameters on the protocol value itself; they are choices made by each conformance.

Primary associated types

Name an associated type in the protocol declaration when clients commonly want to constrain it:

SWIFT
import UIKit
​
protocol ImageCache<Image> {
    associatedtype Image
    func image(forKey key: String) -> Image?
    func insert(_ image: Image, forKey key: String)
}
​
func showCachedImage<C: ImageCache<UIImage>>(
    from cache: C,
    key: String
) -> UIImage? {
    cache.image(forKey: key)
}

This is useful for generic constraints and constrained existentials such as any ImageCache<UIImage>. It does not mean every associated type should be primary; expose only the relationship clients need to name.

Generics: preserve the relationship

Generics let one implementation work with many types while retaining each concrete type at compile time.

SWIFT
func first<Element>(in values: [Element]) -> Element? {
    values.first
}
​
struct Box<Value> {
    var value: Value
}
​
func sorted<Value>(
    _ values: [Value]
) -> [Value] where Value: Comparable {
    values.sorted()
}

The constraint Value: Comparable gives the function the operation it needs. Prefer the narrowest useful constraint instead of requiring a large protocol such as Codable when only Decodable is needed.

Generic constraints and where

SWIFT
func pair<C: Collection, D: Collection>(
    _ left: C,
    _ right: D
) -> (Set<C.Element>, [D.Element])
where C.Element: Hashable, D.Element == String {
    (Set(left), Array(right))
}

Use a where clause to express relationships between types, associated types, and values. Common forms include:

SWIFT
T: Codable                  // T conforms to a protocol
T == User                   // T is exactly User
C.Element: Hashable         // constrain an associated type
T: ~Copyable                // allow a noncopyable generic argument (advanced)

The last form is for advanced ownership APIs; ordinary iOS model code usually does not need it.

some versus any versus a generic parameter

FormWhat it meansUse it when
T: PA named generic type conforms to PYou need to relate the type across arguments, return values, or other constraints
some P in a returnOne hidden, fixed concrete result type conforms to PReturning a concrete implementation while keeping it out of the public signature; common in SwiftUI
some P in a parameterAn unnamed generic parameter constrained to PA short single-parameter generic function
any PA runtime-erased existential holding a value conforming to PThe concrete type may vary at runtime or a dependency is stored behind an interface
SWIFT
protocol Shape {
    var area: Double { get }
}
​
struct Circle: Shape {
    let radius: Double
    var area: Double { .pi * radius * radius }
}
​
func largestArea<S: Shape>(in shapes: [S]) -> Double {
    shapes.map(\.area).max() ?? 0
}
​
func makeShape() -> some Shape {
    Circle(radius: 10)
}
​
let mixedShapes: [any Shape] = [Circle(radius: 2), Circle(radius: 4)]

some in a result position still has one underlying concrete type chosen by the function. A function cannot return a Circle on one branch and an unrelated Rectangle on another as the same some Shape result. Use an enum, a common concrete wrapper, or an existential when the runtime type must vary.

any can add indirection and hide type relationships. Keep a generic parameter when the caller benefits from the relationship, and use any where runtime substitution is the actual requirement. In Swift 5.7 and later, writing any makes existential use explicit.

A common existential limitation

This is easy to store because the protocol has no associated type in its requirements:

SWIFT
protocol UserLoading {
    func loadUser(id: UUID) async throws -> User
}
​
struct UserScreenModel {
    private let loader: any UserLoading
​
    init(loader: any UserLoading) {
        self.loader = loader
    }
}

When an operation depends on a protocol's associated type or Self, a plain any Protocol may erase the information needed to call that operation. Keep the value generic, constrain the associated type (for example any ImageCache<UIImage>), or introduce a deliberate type-erasing wrapper. Don't reach for Any as a way to silence a type-design problem.

typealias: a second name, not a new type

SWIFT
typealias UserID = UUID
typealias Completion = (Result<Void, Error>) -> Void
typealias UserMap = [UserID: User]

UserID is still exactly UUID: it has the same type identity and does not prevent passing an arbitrary UUID. Use a wrapper struct when you need distinct type safety:

SWIFT
struct UserID: Hashable, Sendable {
    let rawValue: UUID
}

A typealias can make a complicated function or generic type easier to read, but avoid aliases that obscure important domain distinctions.

TermDeclared withMeaning
Type aliastypealias Output = DataNames an already known type
Associated typeassociatedtype OutputA protocol's conformer chooses a type
Generic parameterfunc load<T>(...)A caller supplies a type for this use

Extensions and protocol-oriented design

SWIFT
protocol DisplayName {
    var displayName: String { get }
}
​
extension DisplayName {
    var shortLabel: String {
        String(displayName.prefix(12))
    }
}

Extensions can add methods, computed properties, initializers, nested types, and protocol conformances. They cannot add stored instance properties. Keep protocol extensions focused: generic algorithms and small defaults work well; hidden state and surprising default behavior do not.

Prefer small capability protocols such as UserLoading or ImageCaching when they clarify a real boundary. A protocol for every concrete type adds indirection without automatically improving testability.

Common interview distinctions

  • Protocol vs. class inheritance: protocols describe capabilities and can be adopted by value types; classes provide reference identity and single-superclass inheritance.
  • Generic vs. existential: generics preserve a concrete type relationship; existentials erase the concrete type at the use site.
  • some vs. any: opaque type with one hidden implementation versus runtime-erased value that can hold different implementations.
  • typealias vs. associatedtype: alias renames a type; associated type is a placeholder resolved by conformance.
  • Protocol extension vs. conformance: an extension may provide a default implementation, but only a conformance declares that a type promises the protocol's requirements.

When do you choose an enum instead of a protocol?

Use an enum when the alternatives form a finite set owned by this API and callers benefit from exhaustive handling, especially when each case carries different data. Use a protocol when the set of implementations should be open to independent conformers, or when behavior varies behind a stable capability. An enum is a closed data model; a protocol is an extensible behavioral contract. Do not build a protocol hierarchy just to represent a handful of states that should be exhaustively switched.

Prefer an enumPrefer a protocol
The alternatives are finite and centrally owned.New implementations can be added by separate modules or clients.
Callers should handle every case exhaustively.Consumers need one behavior without knowing implementation details.
Cases naturally carry different associated data.Implementations share a behavioral contract.

Example: request state (idle, loading, loaded, failed) is usually an enum. A service capability such as UserLoading is usually a protocol.

Key Takeaways

  • A protocol describes requirements; construct a conforming concrete type, not the protocol itself.
  • Use let or var to control whether a binding can be reassigned; an existential var can hold different conformers.
  • Prefer generics when an operation needs to preserve relationships between concrete types; use any when runtime substitution is the goal.
  • some hides one fixed implementation, while an associated type lets each conformer choose a related type.
  • Add protocols for meaningful capability boundaries, not automatically for every concrete type.

References