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 Phides one fixed concrete type behind protocolP.any Pstores 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
| Goal | Prefer |
|---|---|
| Store a replaceable dependency | any Protocol |
| Preserve a type relationship across an operation | A generic parameter such as T: Protocol |
| Hide one fixed return implementation | some Protocol |
| Store mixed conforming values | [any Protocol] |
| Let each conformer select a related type | associatedtype |
| Constrain the important associated type at the use site | A primary associated type |
| Create a genuinely distinct domain type | A 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.
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:
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:
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"| Declaration | What 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:
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:
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:
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.
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.
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.
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.
@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.
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
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.
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:
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:
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:
letdeclares a constant binding.fixedis the variable name.: any Absis the declared type: an existential value that can hold a concreteAbsconformer.= 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
let title = "Swift" // Can't reassign the binding.
var page = 1 // Can reassign the binding.
page += 1For 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:
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 mutableUse 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
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, orget set), not storage. A conformer can use stored or computed properties. - A
mutatingrequirement lets a value type change itself. A class implementation does not need themutatingmodifier. Selfrefers 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 exampleany 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.
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:
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.
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
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:
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
| Form | What it means | Use it when |
|---|---|---|
T: P | A named generic type conforms to P | You need to relate the type across arguments, return values, or other constraints |
some P in a return | One hidden, fixed concrete result type conforms to P | Returning a concrete implementation while keeping it out of the public signature; common in SwiftUI |
some P in a parameter | An unnamed generic parameter constrained to P | A short single-parameter generic function |
any P | A runtime-erased existential holding a value conforming to P | The concrete type may vary at runtime or a dependency is stored behind an interface |
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:
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
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:
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.
| Term | Declared with | Meaning |
|---|---|---|
| Type alias | typealias Output = Data | Names an already known type |
| Associated type | associatedtype Output | A protocol's conformer chooses a type |
| Generic parameter | func load<T>(...) | A caller supplies a type for this use |
Extensions and protocol-oriented design
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.
somevs.any: opaque type with one hidden implementation versus runtime-erased value that can hold different implementations.typealiasvs.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 enum | Prefer 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
letorvarto control whether a binding can be reassigned; an existentialvarcan hold different conformers. - Prefer generics when an operation needs to preserve relationships between concrete types; use
anywhen runtime substitution is the goal. somehides 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.
