A constant class can have its properties changed. A constant struct can't. This sounds inconsistent until you remember the signpost โ and then it makes perfect sense.
There's a moment when you're learning Swift where something happens that makes absolutely no sense on the surface. You create a constant class instance โ a let โ and then you change one of its properties. And it works. Swift lets you do it without complaint.
Meanwhile, you try the same thing with a constant struct and Swift immediately shuts you down.
Same keyword. Different behavior. What's going on?
The answer, as with most things in the classes chapter, comes back to the signpost. ๐ฅ
A Quick Reminder About Signposts
When you create a class instance and assign it to a variable, what you're actually holding is a signpost pointing to the data โ not the data itself. The class instance lives somewhere in memory, and your variable just tells Swift where to find it.
This is the fundamental difference between classes and structs. Structs hold their data directly. Classes hold a reference to their data.
Keep that in mind, because it explains everything that follows.
The Constant That Isn't Constant
Here's the thing that trips people up:
class Ninja {
var name: String
var powerLevel: Int
init(name: String, powerLevel: Int) {
self.name = name
self.powerLevel = powerLevel
}
}
let naruto = Ninja(name: "Naruto", powerLevel: 9000)
naruto.powerLevel = 9001 // โ
This works fine
Wait. naruto is a let. How is this allowed?
Because let naruto doesn't mean "the Ninja named Naruto is frozen forever." It means "the signpost called naruto is locked in place โ it will always point to this specific Ninja instance."
The signpost is constant. The data it points to is not.
Changing naruto.powerLevel doesn't move the signpost. It doesn't replace the Ninja with a different Ninja. It just walks to the end of the signpost and changes a value there. The signpost itself didn't go anywhere.
What let Actually Locks
So what would let naruto prevent?
This:
let naruto = Ninja(name: "Naruto", powerLevel: 9000)
naruto = Ninja(name: "Sasuke", powerLevel: 8500) // โ Not allowed
That's what you can't do. You can't point naruto at a completely different Ninja. The signpost is locked โ you can't turn it to face a new direction. But you can absolutely change things at the end of the signpost.
Why Structs Work Differently
Now here's why constant structs don't allow property changes:
struct AnimeCharacter {
var name: String
var powerLevel: Int
}
let goku = AnimeCharacter(name: "Goku", powerLevel: 9001)
goku.powerLevel = 9002 // โ This won't compile
Structs don't use signposts. They hold their data directly. So let goku means "the value stored here is constant" โ and that value includes everything inside it.
Think of it this way: a struct is like the number 5. When you write let x = 5, you're saying x is 5, permanently. You can't say "well, I'll keep x but just change one digit of the 5." The 5 is the whole thing. Changing any part of a struct means replacing the entire struct โ and you can't replace a constant.
So with structs:
var goku = AnimeCharacter(name: "Goku", powerLevel: 9001)
goku.powerLevel = 9002 // โ
Works โ goku is a var, so the whole struct can be replaced
With var goku, Swift can swap out the entire struct value for a new one with the updated property. With let goku, it can't, because that would mean destroying and recreating a constant.
The Four Combinations
This gives us four different situations, and they're all worth understanding:
1. Constant class, constant property
let naruto = Ninja(name: "Naruto", powerLevel: 9000)
// naruto.powerLevel = 9001 โ โ can't change a let property
// naruto = Ninja(...) โ โ can't reassign the signpost
The signpost is locked, and the name tag is written in permanent ink. Nothing changes.
2. Constant class, variable property
let naruto = Ninja(name: "Naruto", powerLevel: 9000)
naruto.powerLevel = 9001 // โ
โ can change the var property
// naruto = Ninja(...) โ โ still can't reassign the signpost
The signpost is locked, but the data at the end of it can still be updated. This is the one that surprises people.
3. Variable class, constant property
var naruto = Ninja(name: "Naruto", powerLevel: 9000)
// naruto.powerLevel = 9001 โ โ can't change a let property
naruto = Ninja(name: "Sasuke", powerLevel: 8500) // โ
โ can point at a new Ninja
You can swing the signpost to point at a completely different ninja, but once you're there, their permanent ink properties can't be changed.
4. Variable class, variable property
var naruto = Ninja(name: "Naruto", powerLevel: 9000)
naruto.powerLevel = 9001 // โ
naruto = Ninja(name: "Sasuke", powerLevel: 8500) // โ
Maximum flexibility. The signpost can move, and the data can change. This is the most permissive option.
One Nice Bonus: No mutating Needed
Remember from the structs chapter how methods that changed properties had to be marked mutating? Classes don't have that requirement.
The reason structs need mutating is so Swift can protect you from calling a property-changing method on a constant struct โ it can check at compile time whether the method would be allowed. With classes, the check is simpler: just look at whether the property itself is var or let. Swift doesn't need the mutating label to know what's allowed, because the class instance's own constancy doesn't affect its properties the same way.
The One Thing To Hold Onto
A let on a class locks the signpost, not the data. You can still change variable properties on a constant class โ you just can't point that constant at a different instance entirely.
A let on a struct locks everything, because the struct is the data. There's no signpost to separate the container from the contents.
Once that distinction is clear, the four combinations stop feeling arbitrary and start feeling like exactly the right behavior for each type. ๐ธ
This article was written by me; AI was used to improve grammar and readability.
Top comments (12)
I like the โsignpostโ analogyโit makes the let behavior much easier to visualize.
One thing Iโd add is that this distinction becomes much more important when designing APIs than when just learning the language. A let reference to a class often looks immutable, but it doesnโt guarantee immutability unless the object itself is designed that way.
Thatโs one reason value types (struct) are often preferred for models in Swift. Their immutability is much easier to reason about, especially when passing data across threads or between different parts of an application.
So the interesting distinction isnโt just reference vs valueโitโs reference identity vs state mutability. Once those become separate concepts, the behavior feels completely consistent.
"Reference identity vs state mutability" is the precise distinction that makes the whole thing click โ because the confusion usually comes from treating them as the same thing, which works fine for structs but falls apart for classes. A let reference guarantees identity stability (this variable always points at the same object) but says nothing about state stability (what that object contains can still change freely). Once those are properly separated in your mental model, the behavior stops feeling inconsistent and starts feeling like a deliberate design choice about which guarantee you actually need.
The API design point is the one I hadn't emphasized enough โ because in a learning context the examples are self-contained enough that the practical consequences of "looks immutable but isn't" don't really surface. In a real API, a caller receiving a let reference to a class instance has no way of knowing whether the object's state is safe to cache, share across threads, or rely on not changing between two function calls. That's exactly the gap that makes structs the default preference for models โ the immutability guarantee is structural rather than just conventional, which is a much stronger thing to hand to a caller. Really appreciate you adding this layer.
Exactly. And thatโs where API contracts become more interesting than language syntax.
Identity stability tells me what object Iโm talking to, but state stability tells me whether I can safely reason about it over time.
Thatโs also why Swiftโs modern concurrency model leans so heavily toward value semantics and Sendable. An immutable value can cross isolation boundaries with far fewer assumptions than a shared mutable reference.
So Iโd say the question isnโt โclass or struct?โ but rather โdo I need identity, or do I need predictable state?โ
"Do I need identity, or do I need predictable state?" is such a better question to ask at the design stage than "class or struct?", because it forces you to think about what the data actually needs to guarantee rather than reaching for a type and then working backwards. Identity tells you who you're talking to across time, state stability tells you whether what they tell you is safe to rely on. Most of the time in a data model you need the second thing and don't actually need the first, which is exactly why structs end up being the right default for most models.
The Sendable connection is the one I'm looking forward to covering properly when the series gets to concurrency, because it makes the value semantics preference structural rather than conventional. An immutable value can cross isolation boundaries because the compiler can verify there's nothing to race on. A shared mutable reference requires explicit coordination that the programmer has to get right every time. Same principle as the API design point, just enforced at a different level. Really appreciate this whole thread, it's covered territory the article itself didn't reach.
I think thatโs also why Swiftโs evolution has consistently pushed developers toward making semantics explicit instead of implicit.
Value semantics, Sendable, actor isolation, ownershipโฆ theyโre all solving different problems, but theyโre built around the same idea: make the guarantees visible to both the compiler and the programmer.
Once you start thinking in terms of what guarantees your type provides, many language features stop feeling like separate rules and start looking like pieces of the same design philosophy.
Really enjoyed this discussion. Looking forward to the concurrency part of the series. ๐
You're absolutely right: Swift's evolution is a story of the transition from implicit conventions to explicit architectural guarantees. When the compiler takes on the role of a strict enforcer, developers no longer need to keep hundreds of implicit nuances about thread safety in mind. A lengthy payout process often spoils the overall experience, even during the most successful and profitable sessions. Innovative Fast Payout Casinos lgl.io/ solve this problem, ensuring that players retain only positive feelings from their wins. Quick access to their winnings allows players to feel in control and motivates them to return to the platform again and again.
"Make the guarantees visible to both the compiler and the programmer" is such a clean way to describe the through-line across all of those features. They look like separate language additions on the surface, but they're all doing the same thing at different layers, surfacing something that used to be implicit and conventional into something the compiler can verify and enforce. Once you see that pattern, the language starts feeling less like a collection of features and more like a coherent philosophy that keeps expressing itself in new contexts.
Really enjoyed this discussion too, it covered a lot more ground than the article itself did. Looking forward to getting to concurrency in the series, it's where a lot of these threads are going to converge.
Solid mobile development content. Cross-platform is compelling but the native integration layer often determines success โ have you found that custom platform channels or native module integration becomes a bottleneck?
Thanks for reading! This series is focused on native Swift/iOS development for now, so cross-platform integration is a bit outside the current scope.
Focusing purely on the native ecosystem makes perfect sense, especially when diving into the specific memory management and lifecycle quirks of Swift classes. Mastering these core language features first is definitely the right approach before worrying about cross-platform abstractions. Do you plan to cover how these native variable patterns influence overall architecture decisions in larger iOS codebases later in the series?
Yes, definitely planning to get there! The series is building toward real app architecture gradually, so once the language fundamentals are solid the plan is to connect them to how you'd actually structure a larger SwiftUI codebase, where class vs struct choices, access control, and memory management all start to matter in ways that aren't obvious from small examples. Still a few topics away but it's very much on the roadmap. ๐
That progression from core fundamentals to real-world SwiftUI architecture is exactly what developers need to see. Watching how memory management and class versus struct decisions actually impact a larger codebase is where the real learning happens. I am curious to see how you plan to tackle state management and dependency injection when you bridge that gap.