Tutorials Ultimate Swift Series Chapter 19

Protocols

SwiftChapter 19 of the Ultimate Swift Series42 minMay 31, 2026Intermediate

You've now met three named types: structs, classes, and enums. There's a fourth, and it's a different animal. A struct, class, or enum is something you create instances of. A protocol is something you don't -- it has no instances, no storage, no implementation. It's a contract: a list of capabilities a type promises to provide, with the actual code left entirely to whoever signs it.

Because they describe a shape rather than a thing, protocols are often called abstract types. And you've been leaning on them since your very first program without noticing -- ==, sorting an array, using a value as a dictionary key, printing a value -- every one of those is powered by a protocol in the standard library. This chapter pulls back the curtain on how they work and why they're the backbone of idiomatic Swift.

We'll build the running examples around something this app does constantly: pushing builds and assets to App Store Connect.

Defining a Protocol

You declare a protocol much like any other type -- the keyword, a name, and a body of requirements. Here's the abstract idea of "something that can be uploaded":

protocol Uploadable {
    /// A human-readable description of this upload's state.
    func summary() -> String
}

The body looks like a type with the implementations deleted. summary() has a signature but no { ... } -- the protocol says what must exist, never how. That's why you can't create one:

let thing = Uploadable()   // ⛔ 'Uploadable' cannot be constructed; it's a protocol

Flesh out the contract. An upload can be pushed forward chunk by chunk and cancelled, and both of those change its state:

protocol Uploadable {
    func summary() -> String
 
    /// Send the next chunk of bytes.
    mutating func advance()
 
    /// Abort and reset progress to zero.
    mutating func cancel()
}

You mark advance() and cancel() as mutating because conforming value types will need to modify their own state inside them. (You met mutating on structs back in the Methods chapter; here it appears in the contract so the protocol can be adopted by structs as well as classes.)

Protocols can require properties, too -- but with a twist. You must spell out whether each is gettable or gettable-and-settable:

protocol Uploadable {
    func summary() -> String
    mutating func advance()
    mutating func cancel()
 
    /// Bytes uploaded so far.
    var bytesSent: Int { get set }
 
    /// The largest payload this kind of upload accepts.
    static var maxBytes: Int { get }
}

The { get set } on bytesSent says conformers must make it both readable and writable; { get } on maxBytes requires only that it be readable. Crucially, the protocol makes no claim about storage -- a conformer can satisfy a { get set } requirement with a plain stored property or a computed one with a getter and setter. That's the whole point of an abstract type: it describes the interface, not the implementation. The static on maxBytes marks it as belonging to the type itself, not to any one instance -- every upload of a given kind shares the same ceiling.

Adopting a Protocol

A class, struct, or enum can adopt a protocol; once it implements every requirement, it conforms. The syntax is a colon after the type name -- the same punctuation as class inheritance:

class ResumableUpload: Uploadable {
}

As written, that won't compile -- ResumableUpload promised to be Uploadable but delivered nothing. Fill in the requirements:

class ResumableUpload: Uploadable {
    var bytesSent = 0
    static var maxBytes: Int { 50_000_000 }
 
    func summary() -> String {
        "Resumable upload — \(bytesSent) bytes sent"
    }
 
    func advance() {
        bytesSent = min(bytesSent + 1_000_000, Self.maxBytes)
    }
 
    func cancel() {
        bytesSent = 0
    }
}

Notice advance() and cancel() are not marked mutating here, even though the protocol required it and they clearly change bytesSent. That's because ResumableUpload is a class -- a reference type whose methods can always mutate the instance. mutating only matters for value types. (Also note Self.maxBytes -- capital-S Self means "this conforming type," so it reads the right static value.)

Now write the same idea as a struct, but conform it a little differently:

struct OneShotUpload {
    var bytesSent = 0
    static var maxBytes: Int { 5_000_000 }
 
    func summary() -> String {
        "One-shot upload — \(bytesSent) bytes sent"
    }
 
    mutating func advance() {
        bytesSent = min(bytesSent + 5_000_000, Self.maxBytes)
    }
 
    mutating func cancel() {
        bytesSent = 0
    }
}
 
extension OneShotUpload: Uploadable {}

OneShotUpload has everything Uploadable asks for, but the struct's own declaration doesn't say so. Implementing the requirements isn't enough -- conformance must be declared explicitly. Here it's declared in an extension (extension OneShotUpload: Uploadable {}), with an empty body because the type already satisfies the contract. As a value type, its advance() and cancel() do need mutating.

Adding conformance through an extension is a genuinely useful trick called retroactive modeling: you can make a type conform to a protocol after the fact, even a type whose source you don't own (one from a framework, say). You re-open it and bolt the conformance on.

Extensions can't add stored properties

You can declare methods, computed properties, and conformances in an extension, but not stored properties -- those must live in the type's original definition (or, for a class, a subclass). So retroactive conformance works only when the requirements can be met with computed properties and methods. A protocol that demands new storage can't be satisfied from an extension alone.

Mini-exercise

Define a protocol Estimable with a single read-only requirement, var estimatedBytes: Int { get }. Conform three small structs -- Thumbnail, Trailer, and Manifest -- to it, each returning a different estimate. Put one of each into an [any Estimable] array and compute the total with reduce. (You'll meet any properly in a moment -- for now, read [any Estimable] as "an array of assorted things that are Estimable.")

Default Implementations

Look back at the two conformers: their cancel() methods are byte-for-byte identical. Duplication like that is a smell, and protocols have a cure -- protocol extensions that supply a default implementation:

extension Uploadable {
    mutating func cancel() {
        bytesSent = 0
    }
}

Now any type that conforms to Uploadable gets cancel() for free and can delete its own copy. A conformer that needs something extra -- flushing a buffer, releasing a file handle -- can still write its own cancel(), and that version wins. The default is a fallback, not a cage.

Protocol extensions can also add brand-new members that aren't part of the formal contract:

extension Uploadable {
    /// Progress as a value from 0.0 to 1.0.
    var fractionComplete: Double {
        Double(bytesSent) / Double(Self.maxBytes)
    }
}

Every conforming type now has fractionComplete -- you wrote it once, for all of them. But there's a sharp difference between these two kinds of extension member, and it trips people up:

The rule of thumb: if you want conformers to be able to customize a behavior, declare it in the protocol. If it's a fixed convenience derived from other requirements, an extension-only member is fine.

Faking Default Parameters

Functions can have default parameter values; protocol requirements cannot. This won't compile:

enum RetryDelay {
    case immediate, standard, backoff
}
 
protocol Retryable {
    mutating func retry(after delay: RetryDelay = .standard)   // ⛔ default arguments
                                                               //    not allowed here
}

The workaround uses the default-implementation trick you just learned. Require the full method, then add a no-argument convenience in an extension that fills in the default:

protocol Retryable {
    mutating func retry(after delay: RetryDelay)
}
 
extension Retryable {
    mutating func retry() {
        retry(after: .standard)
    }
}

A conformer still has to implement retry(after:) for every delay, but it gets the zero-argument retry() -- standard delay assumed -- automatically.

Requiring Initializers

A protocol can't be initialized, but it can demand that conformers provide specific initializers:

protocol APISession {
    var token: String { get }
    init(token: String)
    init?(resuming saved: String)
}

Any APISession must offer both a plain initializer and a failable one. When a class conforms, those initializers have to be marked required -- which guarantees every subclass provides them too, keeping the conformance intact down the hierarchy:

class JWTSession: APISession {
    let token: String
 
    required init(token: String) {
        self.token = token
    }
 
    required init?(resuming saved: String) {
        guard !saved.isEmpty else { return nil }
        self.token = saved
    }
}

If you marked JWTSession as final, you could drop required -- a class that can't be subclassed has no descendants to enforce the requirement on, so Swift doesn't ask. The failable init?(resuming:) works exactly as it did for enums: it returns nil when the input can't produce a valid instance (here, an empty saved token).

Protocol Inheritance

Uploadable describes any upload. But resumable uploads have something one-shot uploads don't -- a token to pick up where they left off. You can express "an Uploadable, plus more" by having one protocol inherit from another:

protocol ResumableUploadable: Uploadable {
    var resumeToken: String { get }
}

A type marked ResumableUploadable must satisfy both its own requirement (resumeToken) and everything in Uploadable. Just like class subclassing, this creates an "is-a" relationship: every ResumableUploadable is an Uploadable. Extend the class to opt in:

extension ResumableUpload: ResumableUploadable {
    var resumeToken: String { "tok-\(bytesSent)" }
}

Using Protocols for Polymorphism

The payoff, same as with class hierarchies, is polymorphism -- write code against the abstract type and it works for every conformer, whether struct, class, or enum. Say you want to cancel a batch of uploads:

func cancelAll(_ uploads: [any Uploadable]) {
    uploads.forEach { upload in
        upload.cancel()   // ⛔ cannot use mutating member on immutable value
    }
}

The compiler stops you. cancel() is mutating, and forEach hands its closure an immutable copy of each element -- you can't mutate it. Because some conformers are value types, Swift won't let you mutate one through an immutable binding. Reach for the array's indices and an inout parameter instead:

func cancelAll(_ uploads: inout [any Uploadable]) {
    for index in uploads.indices {
        uploads[index].cancel()
    }
}

Two things changed. inout makes the array itself modifiable, so mutating its elements is allowed. And instead of iterating the elements directly, you iterate the indices and reach back into the array by subscript -- uploads[index] is a mutable slot, so calling a mutating method on it succeeds.

Notice the type: [any Uploadable]. The keyword any marks an existential -- a box that can hold any concrete type conforming to Uploadable, with its real type erased at compile time. Writing any Uploadable (rather than bare Uploadable) makes that box explicit, and it signals a small runtime cost: the box has to store and dynamically dispatch through whatever's inside. Modern Swift wants you to be deliberate about that cost, so any is the right habit -- and in some cases the compiler now requires it.

Associated Types

Sometimes a protocol needs to refer to a type it can't pin down in advance -- one that each conformer chooses for itself. An asset package, for instance, carries a manifest, but a screenshot package's manifest (a list of filenames) looks nothing like a binary package's (a single checksum). Declare the unknown with associatedtype:

protocol Packageable {
    associatedtype Manifest
    var manifest: Manifest { get }
}

Manifest is a placeholder. The protocol says "there's some manifest type here," and leaves the choice to each conformer:

struct ScreenshotPackage: Packageable {
    var manifest: [String]          // Manifest is inferred as [String]
}
 
struct BinaryPackage: Packageable {
    typealias Manifest = Int        // or state it explicitly
    var manifest: Int
}

ScreenshotPackage lets Swift infer Manifest from the type of manifest; BinaryPackage spells it out with typealias (usually unnecessary, but allowed when you want it explicit). The two conformers now have genuinely different contracts -- the meaning of Packageable flexes per adopter.

That flexibility has a cost: because the manifest type isn't known up front, you can't use Packageable as a plain variable type. You must wrap it in any:

let packages: [any Packageable] = [
    ScreenshotPackage(manifest: ["home.png", "detail.png"]),
    BinaryPackage(manifest: 0xC0FFEE),
]

For protocols without associated types, any is encouraged but optional. For protocols with them, it's required -- the existential box is doing real work to hide the differing underlying types behind one interface.

Conforming to Many Protocols

A class can inherit from only one class. But any type -- class, struct, or enum -- can conform to as many protocols as you like. That's Swift's answer to the limits of single inheritance. Split "can be chunked" into its own small protocol:

protocol Chunked {
    var chunkCount: Int { get }
    var chunkSize: Int { get }
}
 
extension ResumableUpload: Chunked {
    var chunkCount: Int { 50 }
    var chunkSize: Int { 1_000_000 }
}

ResumableUpload now conforms to Uploadable, ResumableUploadable, and Chunked -- a kind of multiple inheritance that subclassing could never give you, because each protocol contributes an independent set of capabilities.

Ordering: base class first

When a class both subclasses another class and adopts protocols, the superclass comes first in the list, then the protocols: class FastResumableUpload: ResumableUpload, Chunked, CustomStringConvertible. Structs and enums have no superclass, so they just list protocols.

Composition, any, and some

Sometimes a function needs a value that satisfies several protocols at once. Combine them with the & composition operator. Suppose you want to abort a transfer and report how many chunks it spanned -- you need both Uploadable (for cancel()) and Chunked (for chunkCount):

func abort(_ transfer: inout any Uploadable & Chunked) {
    transfer.cancel()
    print("Aborted across \(transfer.chunkCount) chunks.")
}

any Uploadable & Chunked is an existential box requiring both conformances. But calling it reveals an awkwardness:

var upload: any Uploadable & Chunked = ResumableUpload()
abort(&upload)   // works — but `upload` must be declared as the existential type

To mutate an existential through inout, the variable has to be declared as exactly that existential type. You can't pass a plain ResumableUpload: Swift would box it into a temporary any Uploadable & Chunked, mutate the box, and throw it away -- leaving your original untouched. (Helpfully, it's a compile error rather than a silent bug.)

The cleaner fix swaps any for some:

func abort(_ transfer: inout some Uploadable & Chunked) {
    transfer.cancel()
    print("Aborted across \(transfer.chunkCount) chunks.")
}
 
var upload = ResumableUpload()   // no existential annotation needed
abort(&upload)

some Uploadable & Chunked is not a box. It tells the compiler to generate a specialized version of abort for each concrete type that's both Uploadable and Chunked -- abort becomes a fully generic function. No box, no erasure, no inout-existential headache. The distinction in one line: any is one boxed type that can hold many; some is many specialized copies, one per concrete type. That some quietly turns a function generic is your first glimpse of how thoroughly protocols and generics are wired together -- the subject of the next chapter.

When You Need Reference Semantics

Protocols can be adopted by value types and reference types alike, so what semantics does a protocol-typed variable have -- value or reference? The honest answer: whichever the concrete type underneath has. A small demonstration with an editable draft:

protocol Editable {
    var draftTitle: String { get set }
}
 
class SharedDraft: Editable {
    var draftTitle: String
    init(draftTitle: String) { self.draftTitle = draftTitle }
}
 
struct DraftSnapshot: Editable {
    var draftTitle: String
}

Put a class instance behind the protocol type and you get reference semantics -- the copy is an alias:

var editable: any Editable = SharedDraft(draftTitle: "Working title")
var copy = editable
 
editable.draftTitle = "Final title"
editable.draftTitle   // "Final title"
copy.draftTitle       // "Final title"  — same object

Put a struct instance behind the very same variable and you get value semantics -- the copy is independent:

editable = DraftSnapshot(draftTitle: "Working title")
copy = editable
 
editable.draftTitle = "Final title"
editable.draftTitle   // "Final title"
copy.draftTitle       // "Working title"  — separate value

When a protocol is meant to be adopted only by classes -- and you want to rely on reference semantics -- say so by constraining it to AnyObject:

protocol Editable: AnyObject {
    var draftTitle: String { get set }
}

Now only classes can conform, and the reference behavior is guaranteed. (An older class keyword does the same job, but AnyObject is the preferred spelling today.)

Protocols Are More Than Bags of Syntax

A protocol checks shape: the methods, properties, and initializers a conformer must provide. What it can't check is meaning. Your Uploadable contract says cancel() must exist and be mutating -- but nothing in the language forces cancel() to actually reset bytesSent to zero. A conformer could implement it to do nothing, or to increase the count, and still compile. Likewise a protocol might require an operation to be O(1), or a property to never return an empty string -- guarantees that live only in documentation and the conformer's good faith.

This is why people say protocols are "more than bags of syntax." The compiler verifies the signatures; you are responsible for honoring the intent behind them. And it's exactly why Swift makes you declare conformance explicitly instead of inferring it from matching method names -- writing : Uploadable is you signing the contract, promising you've met not just its syntax but its meaning.

Protocols in the Standard Library

Here's where it pays off. Swift's standard library is built out of protocols, and conforming your own types to them unlocks piles of built-in behavior. A few you'll use constantly:

Equatable

You compare Ints and Strings with == every day. That isn't magic reserved for built-in types -- those types simply conform to Equatable, whose entire contract is one static operator:

protocol Equatable {
    static func == (lhs: Self, rhs: Self) -> Bool
}

Teach your own type what equality means by implementing it. A semantic version is equal to another when all three components match:

struct SemanticVersion {
    let major: Int
    let minor: Int
    let patch: Int
}
 
extension SemanticVersion: Equatable {
    static func == (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool {
        lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch
    }
}
 
SemanticVersion(major: 1, minor: 4, patch: 7)
    == SemanticVersion(major: 1, minor: 4, patch: 7)   // true
You usually don't write this

For a struct or enum whose every stored property (or associated value) is already Equatable, Swift synthesizes == for you -- just declare conformance (struct SemanticVersion: Equatable {}) and skip the implementation. You only hand-write == for classes, or when "equal" means something other than "all fields match." The manual version above is shown so you can see what the synthesis is doing for you.

Comparable

Comparable refines Equatable, adding the ordering operators. In practice you implement just one of them -- < -- and the standard library derives <=, >, and >= from your < and ==:

extension SemanticVersion: Comparable {
    static func < (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool {
        (lhs.major, lhs.minor, lhs.patch) < (rhs.major, rhs.minor, rhs.patch)
    }
}

(That tuple comparison sorts by major, then minor, then patch -- exactly how versions order.) Conform to Comparable and a whole arsenal of "free" collection methods lights up, because the standard library now knows how to order your values:

var releases = [
    SemanticVersion(major: 2, minor: 1, patch: 0),
    SemanticVersion(major: 1, minor: 9, patch: 3),
    SemanticVersion(major: 2, minor: 1, patch: 1),
]
 
releases.sort()        // 1.9.3, 2.1.0, 2.1.1
releases.max()         // 2.1.1
releases.min()         // 1.9.3
releases.contains(SemanticVersion(major: 1, minor: 9, patch: 3))   // true

You implemented one operator; you got sorting, min, max, and more.

Hashable

Hashable (a refinement of Equatable) is the ticket to using a value as a dictionary key or a set member. Value types get it synthesized, but for a class you write it by hand -- and the golden rule is that whatever you compare in ==, you must also feed to the hasher:

class BetaTester {
    let email: String
    let name: String
    let group: String
 
    init(email: String, name: String, group: String) {
        self.email = email
        self.name = name
        self.group = group
    }
}
 
extension BetaTester: Hashable {
    static func == (lhs: BetaTester, rhs: BetaTester) -> Bool {
        lhs.email == rhs.email && lhs.name == rhs.name && lhs.group == rhs.group
    }
 
    func hash(into hasher: inout Hasher) {
        hasher.combine(email)
        hasher.combine(name)
        hasher.combine(group)
    }
}

hash(into:) hands each property to the passed-in Hasher, which does the actual mixing. Keeping == and hash(into:) in sync matters: two values that are equal must produce the same hash, or dictionaries and sets misbehave. With that in place, a BetaTester can key a dictionary:

let tester = BetaTester(email: "ada@example.com", name: "Ada", group: "Internal")
let buildInvites = [tester: "Build 412"]

Identifiable

Identifiable requires just one thing: a get-only id property whose type is Hashable. It's how SwiftUI tells list rows apart, among other uses. Pick a property that's genuinely unique per instance:

extension BetaTester: Identifiable {
    var id: String { email }
}

email works because no two testers share one (and String is Hashable). You wouldn't use name -- two testers could both be "Ada."

CustomStringConvertible

Print a type with no special handling and you get something unhelpful:

print(tester)   // a vague, default description

Conform to CustomStringConvertible -- a single description property -- to control how your type appears in print() and string interpolation:

extension BetaTester: CustomStringConvertible {
    var description: String { "\(name) <\(email)>" }
}
 
print(tester)   // Ada <ada@example.com>

Its sibling CustomDebugStringConvertible adds a debugDescription surfaced by debugPrint(), handy for richer output that only shows up while you're debugging.

Challenge

Challenge 1: a release-pipeline task system

Model the jobs a release pipeline runs on its artifacts using a family of protocols -- this exercises composition and protocol-typed arrays together. The duties:

Then:

  1. Define protocols Validatable, Renderable, Transcodable, Compressible, and Publishable, each with one method (a print() body is fine). Create types Screenshot, AppIcon, AppPreview, and Binary, and have each adopt the protocols that fit it -- e.g. Screenshot is Validatable & Renderable & Compressible & Publishable, while AppPreview is Validatable & Transcodable & Compressible & Publishable.
  2. Build homogeneous arrays typed by protocol -- var renderables: [any Renderable], var transcodables: [any Transcodable], and so on -- and add the artifacts that belong in each.
  3. Loop over each array and call the matching job on every element.

Pay attention to which arrays a given artifact lands in: that membership is the design. An AppPreview should appear in transcodables and compressables but never in renderables, and the compiler enforces it.

Key Points

You keep brushing up against one idea from every angle now -- some turning a function generic, associatedtype deferring a type, the standard library writing one sort() that works for every Comparable. That idea is generics: code written once that works across many types, with protocols as the constraints that keep it honest. Protocols were the contract; generics are what you build on top of them. That's the next chapter, and it's where Swift's type system finally clicks into one picture.

Ch 18: EnumerationsCh 20: Generics
SwiftUIUltimate SwiftUI SeriesSwiftUI tutorials for building native app screens, layouts, navigation, and state-driven interfaces.Ship iOSShip iOS Apps SeriesShipping workflows for iOS apps: signing, TestFlight, App Store Connect, CI, and release hygiene.DeliveryModern Delivery PipelineCI/CD, review, runner, and deploy workflows for teams shipping apps and websites safely.

Ship your apps faster

When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.

Try Simple App Shipper
5 free articles remainingSubscribe for unlimited access