1.6 — Structs, classes, enums, protocols (the four pillars)

Opening scenario

You join a code review and find this PR:

class User {
    var id: UUID
    var name: String
    var email: String
    init(id: UUID, name: String, email: String) { /* ... */ }
}

You leave a review comment: “Should this be a struct?”

The author responds: “Why does it matter?”

How you answer that question — in your head, in a PR, in an interview — defines whether you’re a Swift programmer or a Java/Kotlin programmer typing Swift.

The taxonomy

Swift has named types in four flavors:

KindValue or reference?Inheritance?Best for
structValueNoData models, view state, anything immutable-ish
classReferenceYes (single)Identity, shared mutable state, ObjC interop
enumValueNoClosed sets of cases, state machines, results
actorReference (isolated)NoConcurrency-safe mutable state (Chapter 1.9)

Plus protocol — not a type itself but a contract a type can adopt — which is what makes Swift’s OOP feel different from Java’s or Kotlin’s.

We’ll cover all of these, then end on the question every Swift engineer has to answer: struct or class?

Concept → Why → How → Code

Structs: the default

struct Point {
    var x: Double
    var y: Double

    func distance(to other: Point) -> Double {
        let dx = x - other.x
        let dy = y - other.y
        return (dx*dx + dy*dy).squareRoot()
    }

    // mutating methods must say so explicitly
    mutating func translate(by delta: Point) {
        x += delta.x
        y += delta.y
    }
}

let a = Point(x: 0, y: 0)         // memberwise init for free
var b = a                         // COPY, not a reference
b.x = 5
print(a.x)                        // 0 — a unchanged

Why value semantics matter: when you pass a Point to a function, the function gets a copy. It cannot mutate your Point behind your back. Local reasoning becomes possible.

Classes: when you need identity

class ViewModel {
    var items: [Item] = []
    func reload() { /* ... */ }
}

let vm1 = ViewModel()
let vm2 = vm1                 // SAME instance — both point to the same object
vm2.items.append(Item())
print(vm1.items.count)        // 1 — they share state

// Equality: === is reference identity, == is value equality (if Equatable)
print(vm1 === vm2)            // true

You reach for class when:

  • You need identity (two User instances with the same name are still different users in your domain).
  • You need inheritance (a UIViewController subclass).
  • You’re interoperating with Objective-C (NSObject subclass).
  • You need shared mutable state with reference semantics (a cache, a coordinator).

Enums: pattern matching is the point

Swift enums are dramatically more powerful than C/Java enums. They carry associated values and support methods, computed properties, even protocols.

enum LoadState<T> {
    case idle
    case loading
    case loaded(T)
    case failed(Error)

    var isFinished: Bool {
        switch self {
        case .loaded, .failed: true
        case .idle, .loading:  false
        }
    }
}

let state: LoadState<[Post]> = .loaded([])
switch state {
case .idle:           print("waiting")
case .loading:        print("...")
case .loaded(let xs): print("got \(xs.count)")
case .failed(let e):  print("error: \(e)")
}

This is the single most powerful Swift feature for modeling domain state. Bad code says var isLoading: Bool, var data: [Post]?, var error: Error?. Good code says var state: LoadState<[Post]>.

Raw values (when each case has a primitive underlying value):

enum HTTPStatus: Int {
    case ok = 200
    case notFound = 404
    case serverError = 500
}

let s = HTTPStatus(rawValue: 404)   // HTTPStatus? — Optional

Protocols: contracts that types adopt

protocol Drawable {
    func draw(in context: GraphicsContext)
    var bounds: CGRect { get }
}

struct Circle: Drawable {
    let center: CGPoint
    let radius: Double
    var bounds: CGRect { CGRect(x: center.x - radius, /* … */ ) }
    func draw(in ctx: GraphicsContext) { /* … */ }
}

extension Array where Element == Drawable {
    func drawAll(in ctx: GraphicsContext) {
        forEach { $0.draw(in: ctx) }
    }
}

Protocols are what types can be expected to do; structs/classes/enums are how that’s delivered. Functions can require Drawable instead of caring what kind of thing they got.

Protocol extensions: behavior with no inheritance

Java/Kotlin extract shared behavior via an abstract base class. Swift uses protocol extensions:

protocol Greetable {
    var name: String { get }
}

extension Greetable {
    func greet() -> String { "Hello, \(name)" }  // default implementation
}

struct Person: Greetable { let name: String }
Person(name: "Ada").greet()  // "Hello, Ada"

This is protocol-oriented programming — the design ethos Apple promoted hard at WWDC 2015. Compose behavior into small protocols; let concrete types adopt the protocols they need; share implementations via extensions.

In the wild

  • Codable is a protocol (composed of Encodable and Decodable). Conform your model struct and free JSON encoding/decoding appears via the compiler-generated implementation.
  • Identifiable, Hashable, Equatable — used everywhere in SwiftUI’s ForEach, in Set, in Dictionary keys. The compiler can synthesize all three.
  • View in SwiftUI is a protocol, not a class. Every SwiftUI view is a struct (yes, structs!) conforming to View.
  • Sendable — the Swift 6 concurrency protocol that marks types safe to cross actor boundaries.
  • MVVM/MVI architectures: the M (model) is usually a struct, the VM (view model) is usually a class with ObservableObject or @Observable, the V (view) is a struct.

Common misconceptions

  1. “Classes are more ‘real’ OOP than structs.” This is Java thinking. In Swift the default is struct; classes are a specialization for when you need their unique features.

  2. “Structs are slow because they copy.” COW (copy-on-write) makes struct copies cheap for the standard collections. For your own structs, copying is just member-wise — small. The compiler also optimizes returns to avoid copies.

  3. “You can’t have polymorphism with structs.” False — protocols give you polymorphism without inheritance. func render(_ shapes: [any Drawable]) accepts circles, squares, paths, all heterogeneous.

  4. enum is for fixed lists like Color { red, green, blue }.” That’s the C view. In Swift, enums with associated values are the canonical way to model “one of these N possibilities, each with different data.”

  5. protocols are just Java interfaces.” Similar, but with two key differences: (a) protocols can have default implementations (extensions), and (b) protocols can constrain associated types (we’ll see this in Generics).

Seasoned engineer’s take

Apple’s official advice is: start with a struct. Move to a class only when you have a reason. Reasons:

  • You need identity — two distinct objects with identical fields are different (a User in your domain, a network session).
  • You need inheritance — typically because UIKit/AppKit forces it on you.
  • You need shared mutable state with reference semantics (a cache, an in-memory store).
  • You need Objective-C interop (NSObject subclass for KVO, NSCoding, etc.).

For everything else — your Article, your BlogPost, your Profile, your view-state, your DTOs from the network — use struct. Value semantics + protocol conformance is the modern Swift idiom.

A heuristic I find useful: does it make sense to compare two instances with ==? If “same data = equal” is your domain rule, struct. If “different objects, even with same data” is the rule, class. (User(id: 1, name: "Ada") == User(id: 1, name: "Ada")true makes sense, so User is a struct.)

The hard cases:

  • Big structs (>200 bytes). Pass-by-value is still cheap (Swift uses register passing where possible), but if you’re holding millions of them, profile.
  • Recursive types (a Node with var children: [Node]). Structs work fine for immutable trees; for mutable recursive structures, classes are often less surprising.
  • Long-lived state that must be unique (like a coordinator object that owns navigation). Classes with final keyword.

TIP: Conform your structs to Equatable, Hashable, Codable proactively. The Swift compiler synthesizes them for free if every stored property already conforms. It costs you nothing and unlocks Set, Dictionary keys, ForEach, JSON I/O.

WARNING: Inheritance with classes is a slippery slope. Three levels deep and you’ll wish you’d composed protocols instead. Apple has explicitly stated the modern Swift recommendation is composition over inheritance. Use final class by default — most classes should be unsubclassable unless they’re explicitly designed to be inherited.

Interview corner

Question: “When would you use a class instead of a struct in Swift?”

Junior answer: “When I need inheritance or when I want shared mutable state.” → Correct, but textbook. They’ll push.

Mid-level answer: “I default to struct for value-semantic models (anything that’s just data). I use class when I need (a) reference identity — like a long-lived ViewModel that the view holds a reference to, (b) inheritance — usually forced by UIKit, (c) Objective-C interop, or (d) when the type is genuinely a thing in the world rather than a value — a cache, a network session manager. With SwiftUI specifically, my models are structs, my view models are @Observable classes.” → Strong.

Senior answer: All of that, plus: “The deeper question is about identity vs. value. User is interesting because reasonable people disagree. Some teams treat User as a value (two Users with the same id are equal, immutable snapshots). Other teams treat User as having identity (the User object you’re holding is the user, mutations propagate). I’d ask: do we need to observe changes to this object in many places? Do we share mutation with state-management infrastructure (@Observable, Redux store)? If yes → class. If we’re passing snapshots around (network DTOs, view state) → struct. I’d also point out that the answer evolves: a User struct + a UserSession class is often cleaner than one giant User class doing both jobs.” → Senior signal: distinguishes data from identity.

Red-flag answer: “Classes are better because they’re faster.” → Both wrong (structs are often faster due to stack allocation and inlining) and outs you as someone who’s never profiled.

Lab preview

Lab 1.C (Protocol-oriented calculator) builds an arithmetic library where every operation is a struct conforming to a BinaryOperation protocol. You’ll see protocol-oriented design in 80 lines.


Next: Swift’s most powerful and most intimidating feature — generics. → Generics and the type system