Why Value Types Should Be Your Default in Swift

I still remember the first time I debugged a tangled mess of reference-type mutations in an iOS app. A simple screen update triggered changes across half a dozen view models, and tracing the source of truth felt like untangling a knot with one hand. That afternoon I rewrote the core model layer with structs, and the bug count dropped instantly. Since then I’ve defaulted to value types in Swift unless I have a concrete reason to reach for a class. Here’s why you should too.
The Swift Type System Favors Structs
Swift’s standard library is built around value types—Int, Double, String, Array, Dictionary, and Set are all structs. That isn’t an accident. It’s a design choice that reflects how Swift wants you to think about data. When you write let count = 5, you’re creating an independent copy of an integer. Nobody expects modifying count to affect some other integer somewhere else. That mental model scales cleanly to custom types.
Structs give you predictable ownership semantics. Every assignment and function call passes a copy, so you never have to wonder whether a distant piece of code might alter your data behind your back. Classes, on the other hand, share a reference. A single didSet observer in one view controller can ripple through your entire app if you’re not careful. I’ve spent too many late nights chasing those ripples.
Immutability by Convention Becomes Immutability by Default
When you declare a struct instance with let, you get true immutability. The compiler enforces it—no property can be changed, even if it’s declared with var inside the struct definition. With classes, let only fixes the reference; the object’s internal state remains mutable unless you’ve manually made every property read-only. That’s a subtle distinction with outsized consequences. In practice it means you can design struct-based data models that are safe to pass around without defensive copying, while class-based models demand constant vigilance.

Thread Safety Without Locks
Multithreaded bugs are notoriously hard to reproduce. If you’re passing a class instance across queues, you need to synchronize access—locks, serial queues, or actors. Structs give each queue its own copy, so there’s no shared mutable state to protect. This alone eliminates an entire category of concurrency defects. When I’m building a networking layer or a background processing pipeline, value types are my first line of defense against race conditions.
Swift’s new actor model works beautifully with classes, but it’s still an opt-in system. Structs give you isolation for free, without requiring you to annotate types or refactor call sites. For many everyday tasks—parsing JSON, formatting a date, building a view’s display data—the copying overhead is negligible and the safety is absolute.
Copy-On-Write Keeps It Efficient
A common objection to value types is performance: “Won’t all that copying slow things down?” Swift’s standard library collections use copy-on-write (CoW) to avoid unnecessary duplication. When you assign an array to a new variable, the underlying storage isn’t copied until one of the variables mutates it. You get the semantics of value types with the efficiency of shared storage in read-heavy scenarios.
You can even implement CoW for your own types when you need large internal buffers. Wrap a class inside a struct and check isKnownUniquelyReferenced(_:) before mutating. I’ve used this pattern for image caches and audio buffers, and it keeps the interface clean while preserving performance. But for the vast majority of model types—user profiles, settings, coordinates, transactions—the raw copy cost is trivial compared to the cognitive cost of shared mutable state.

When a Class Actually Makes Sense
I’m not suggesting you purge classes from your codebase. There are clear cases where reference semantics are the right tool: shared resources like file handles or database connections, identity-sensitive objects like view controllers, or when you need to interoperate with Objective-C APIs that expect NSObject subclasses. The key is to treat classes as the exception, not the rule.
Ask yourself: does this type represent a distinct identity that persists independently of its value? A bank account has an identity even if its balance changes. A UIView has an identity because it occupies a specific point in the view hierarchy. But a UserProfile, a GeoCoordinate, or a PaymentSummary? Those are defined entirely by their data. Two profiles with identical fields should be interchangeable. That’s the hallmark of a value type.
Protocol Conformance Comes Free
Structs get automatic Equatable, Hashable, and Codable conformance when all stored properties conform. This isn’t just boilerplate reduction—it changes how you design APIs. I’ve built entire data layers where every model is a struct, and serialization, comparison, and hashing just work without a single extra line of code. With classes you’re often forced to write manual == implementations or wrestle with identity versus equality semantics. The compiler can’t help you as much because it can’t assume value semantics.
SwiftUI Reinforces the Pattern
If you’re building views with SwiftUI, value types are practically mandatory. SwiftUI’s view structs are lightweight descriptions of your UI, created and discarded at high frequency. The framework compares view bodies efficiently because structs are cheap to diff. When your model data is also a struct, you get predictable redraws driven by @State and @Binding. I’ve seen teams struggle with unexpected view updates because they embedded reference-type models inside observable objects, creating hidden dependencies that SwiftUI’s diffing couldn’t track.
Combine’s publishers and operators also favor value types. A PassthroughSubject or CurrentValueSubject is a class, but the values you send through it should be structs whenever possible. That way downstream subscribers can process data without worrying about mutation from other subscribers. The pipeline becomes deterministic, which makes debugging drastically simpler.
Testing Is Less Painful
Value types make unit tests cleaner because you don’t need to mock complex object graphs. Create a struct, set its properties, pass it into the function under test, and verify the result. No shared state to reset between test cases, no hidden dependencies to inject. I often write tests that construct hundreds of slightly different struct instances to validate edge cases, and the code reads like a specification rather than a ceremony of setup and teardown.
When a test fails, the failure is local. You know exactly which input produced which output. With class-heavy architectures I’ve wasted hours tracking down test pollution where one test’s mutation leaked into another’s. Structs eliminate that entire class of false positives.
A Practical Default for Your Next Feature
Here’s my rule of thumb: start every new type as a struct. If you later discover you need reference semantics—because you’re managing shared mutable state, implementing a delegate pattern, or bridging to Objective-C—convert it to a class. I’ve reversed this decision maybe one time in twenty. The other nineteen, the struct stays and the codebase is better for it.
This isn’t about dogmatism. It’s about reducing the surface area for bugs. Every class you write is a potential source of unintended side effects. Every struct is a self-contained unit of logic that you can read, test, and reason about in isolation. In a language that gives you such strong value-type foundations, reaching for a reference type should be a conscious, justified choice.
Next time you’re modeling a new feature, try starting with a struct. Pay attention to how your code flows, how your tests read, and how many concurrency warnings Xcode throws at you. I suspect you’ll find, as I did, that the default pays for itself quickly.
Frequently Asked Questions
Doesn’t copying large structs hurt performance?
For most app-level types, the copy cost is negligible. Swift’s copy-on-write optimization means collections like Array and Dictionary share storage until mutated. If you’re handling very large data buffers, you can implement CoW manually, but measure first—premature optimization often adds complexity without measurable benefit.
When should I definitely use a class instead of a struct?
Use a class when you need identity (two objects with the same data should not be considered equal), when you’re managing a shared resource like a file handle, when you’re subclassing from an Objective-C base class, or when you need to observe changes with KVO. View controllers, managed objects, and network managers are classic examples.
How do structs handle stored closures or delegates?
Structs can store closures, but closures themselves are reference types, so you need to be mindful of capture semantics. For delegate patterns, classes are often more natural because delegates typically represent identity-based relationships. If you’re building a delegate-like API with structs, consider using a closure-based callback instead.
Are there any downsides to relying on automatic protocol conformance?
Automatic conformance works only when all stored properties conform. If you have a property that’s a class without Equatable conformance, for instance, you’ll need to write custom logic. This is usually a prompt to reconsider whether that property should be a value type too.