Swift’s three ways to model data, and the crucial distinction behind them: structs and enums are value types (copied), classes are reference types (shared). Choosing correctly prevents a whole category of bugs.
A struct groups related data and behavior, and it is a value type: assigning or passing it makes a copy, so changes to one instance never affect another. This predictability is why Swift and SwiftUI favor structs for models and state. Structs get a memberwise initializer for free and are the default choice unless you specifically need reference semantics.
struct Point {
var x: Int
var y: Int
func distanceFromOrigin() -> Double {
Double((x * x + y * y)).squareRoot()
}
}
var a = Point(x: 3, y: 4) // free memberwise init
var b = a // COPY — b is independent of a
b.x = 100
print(a.x, b.x) // 3 100 — 'a' is unaffected (value semantics)
print(a.distanceFromOrigin()) // 5.0A class is a reference type: assigning it shares the same instance, so a change through one reference is visible through all of them. Classes support inheritance, which structs do not. Use a class when you need shared, mutable state or an identity that persists (a view controller, a shared manager); use a struct for plain data. Mixing these up causes subtle "why did this change?" bugs.
class Counter {
var value = 0
func increment() { value += 1 }
}
let c1 = Counter()
let c2 = c1 // SHARED reference — same instance
c2.increment()
print(c1.value, c2.value) // 1 1 — both see the change (reference semantics)
// struct = copied (independent) ; class = shared (same object).
// choose struct by default; class for identity/shared state/inheritance.Swift enums are far more powerful than integer constants: each case can carry associated values of any type, letting you model a fixed set of states precisely. Combined with an exhaustive switch, this makes illegal states unrepresentable — the canonical example being a network result that is either loading, a success with data, or a failure with an error.
enum LoadState {
case loading
case success(data: String) // carries associated data
case failure(error: String)
}
func render(_ state: LoadState) -> String {
switch state { // exhaustive — compiler checks every case
case .loading: return "Loading..."
case .success(let data): return "Got: \(data)"
case .failure(let error): return "Failed: \(error)"
}
}
print(render(.success(data: "hello"))) // Got: hello
// add a case -> every switch stops compiling until you handle it.Types hold stored properties (actual values) and computed properties (calculated on access from other state). Property observers (willSet/didSet) run code when a stored property changes. These let a type expose a clean interface while keeping derived values in sync, without callers needing to know which is which.
struct Temperature {
var celsius: Double {
didSet { print("changed to \(celsius)°C") } // observer
}
// computed property — derived from celsius, no storage
var fahrenheit: Double {
get { celsius * 9 / 5 + 32 }
set { celsius = (newValue - 32) * 5 / 9 }
}
}
var t = Temperature(celsius: 25)
print(t.fahrenheit) // 77.0 (computed)
t.fahrenheit = 212 // sets celsius via the computed setter
print(t.celsius) // 100.0 (+ didSet prints)