Overview

Swift’s type system separates value semantics (structs, enums, tuples) from reference semantics (classes, actors). Value types copy on assignment, giving each variable its own independent version of the data. This page explains when to use each, how the compiler implements copy-on-write for collections, and the narrow set of cases where a class is the right choice. See swift for the broader rules, and swift-actors for concurrency implications.

Reach for struct first

Default to struct for every new type unless you have a specific reason to use class. Structs eliminate aliasing bugs: when you pass a struct to a function, the function receives a copy and cannot affect the caller’s instance.

struct Money {
    let amount: Decimal
    let currency: String
 
    func adding(_ other: Money) -> Money {
        precondition(currency == other.currency)
        return Money(amount: amount + other.amount, currency: currency)
    }
}
 
var price = Money(amount: 9.99, currency: "USD")
var discounted = price          // independent copy
discounted = discounted.adding(Money(amount: -1.00, currency: "USD"))
// price is still 9.99; discounted is 8.99

A struct with all let properties is automatically Sendable, which means it can cross actor boundaries with no extra annotation. See swift-actors.

Use class only when identity or lifecycle is part of the contract

Three situations justify a class:

  1. The type must be observed as a reference. SwiftUI’s @Observable and ObservableObject require reference types.
  2. The type manages an external resource with a clear open/close lifecycle (a database connection, a file handle, a network session).
  3. The type must interoperate with an Objective-C framework that requires a class hierarchy.

Outside these cases, modeling with a struct composes better and is safer across concurrency boundaries. For swiftui view models using the Observation macro, @Observable final class is the correct pattern; it is the framework’s design requirement, not a reason to prefer classes in general.

Understand copy-on-write for standard collections

Swift’s built-in Array, Dictionary, and Set are value types backed by a heap buffer. The copy is deferred: two variables sharing the same buffer incur no cost until one of them mutates.

var original = [1, 2, 3]
var copy = original     // no heap copy yet; both share the buffer
 
copy.append(4)          // copy-on-write triggers here; original is unaffected

Collections with a single owner never allocate a second buffer; the mutation is done in place. Multiple owners pay for a copy only on the first mutation. This makes passing arrays to functions cheap for read-only access.

Custom types can implement copy-on-write manually using isKnownUniquelyReferenced:

final class Buffer {
    var storage: [Int]
    init(_ storage: [Int]) { self.storage = storage }
    func copy() -> Buffer { Buffer(storage) }
}
 
struct LargeDataStore {
    private var _buffer = Buffer([])
 
    private mutating func ensureUniqueBuffer() {
        if !isKnownUniquelyReferenced(&_buffer) {
            _buffer = _buffer.copy()
        }
    }
 
    mutating func append(_ value: Int) {
        ensureUniqueBuffer()
        _buffer.storage.append(value)
    }
}

Implement copy-on-write for custom types only when profiling shows the copy cost is measurable.

Prefer enum over Boolean flags for exclusive states

A Boolean flag that is meaningful only in context of another variable is a sign the state machine is implicit. Encode it as an enum.

// Bad: three Booleans can be in impossible combinations
var isLoading: Bool
var didFail: Bool
var hasData: Bool
 
// Better: all valid states are explicit
enum FeedState {
    case idle
    case loading
    case loaded([Post])
    case failed(Error)
}

An enum makes impossible states unrepresentable. Pattern-matching on it in a switch is exhaustive; the compiler warns about unhandled cases.

Avoid large value types on hot paths

Value semantics is not free of runtime cost when the stack copy is large. Structs with dozens of stored properties and no copy-on-write buffer are copied in full on assignment. Measure before assuming a large struct is faster than a class. Instruments’ Allocations and CPU profiler show copy costs.

In practice the threshold is high: a struct with 8 to 12 word-size fields fits in CPU registers or a small stack frame on ARM64, so copies are fast. Profile first.

mutating methods signal intent; immutable values are safer by default

Mark methods that change stored properties mutating. A let binding refuses to call mutating methods, catching accidental mutation at compile time.

struct Counter {
    private(set) var value: Int = 0
    mutating func increment() { value += 1 }
}
 
let fixed = Counter()
// fixed.increment()  // error: cannot use mutating member on immutable value
var active = Counter()
active.increment()

Use private(set) to expose a read-only view of a property while keeping mutation internal. This is cleaner than a separate read-only computed property.