Deciding between ‘let’ and ‘var’ for Swift struct properties

Wait 5 sec.

When declaring a Swift property, we use either the let or var keyword depending on whether we want our new property to be read-only once assigned through the enclosing type’s initializer, or whether we want to allow the property to be mutated and re-assigned multiple times.However, that’s not the only difference between using let versus var when working with Swift structs — as both approaches also influence the enclosing struct’s behaviors in various ways. Let’s explore!The side-effects of immutabilityLet’s say that we’ve declared a User struct within a project, which currently only contains constant let properties:struct User: Identifiable, Codable { let id: UUID let name: String let bio: String let imageURL: URL?}Besides the fact that neither of those properties can be directly modified when working with a mutable User value, marking imageURL specifically as a let actually influences how our struct’s initializer behaves.When an optional struct property is declared as a let, then we’re always required to pass a value for it when using the default compiler-generated, so-called member-wise initializer. That means that even in situations when a user’s imageURL should be nil, we have to explicitly specify that:let user = User( id: UUID(), name: "John Appleseed", bio: "Famous person within the Apple Cinematic Universe", imageURL: nil)If imageURL was declared as a var instead, then we could’ve simply omitted that parameter above.Whether that’s an advantage or disadvantage likely depends on the situation (and personal taste). Sometimes it’s great that we can’t forget to pass an imageURL, and sometimes the above just leads to unnecessary boilerplate.Another way that let properties differ from var-declared ones is when it comes to default values, since let properties treat such values as constant declarations. For example, let’s say that we wanted to add a default value for our User struct’s id property — to avoid having to manually pass UUID() every time we create a new user:struct User: Identifiable, Codable { let id = UUID() let name: String let bio: String let imageURL: URL?}If id was a var, then the above change would simply mean that our UUID() expression would be used unless we pass an explicit value for that parameter (either when directly initializing User, or when decoding a value from a data format, such as JSON). However, since it’s a let, it now means that we can’t actually pass a value for that property at all — the UUID() expression will always be used, and there’s no way to override that.Since our User type also conforms to Decodable (through the Codable type alias), that actually also means that no id value will ever be decoded, and any such value that’s present within the JSON (or other data format) that we’re decoding from will simply be ignored. In fact, the Swift compiler will even give us a warning when using the above pattern, since it’s likely not the decoding behavior we want our type to have.Manually declared initializersSo what if we wanted to change some of the behaviors that we explored above? One way to do that would be to manually declare our type’s initializer, rather than relying on the member-wise one that the compiler generates for us. For example, let’s say that we wanted to allow call sites to omit the imageURL property if it should simply be nil (without changing it to a var), and/or use a default UUID for the id property if no explicit value was passed. That could be done like this:struct User: Identifiable, Codable { let id: UUID let name: String let bio: String let imageURL: URL? init(id: UUID = UUID(), name: String, bio: String, imageURL: URL? = nil) { self.id = id self.name = name self.bio = bio self.imageURL = imageURL }}The above strikes a quite nice balance between maintaining immutability (if that’s something we want), while still enabling convenience features like default values — at the cost of having to manually write and maintain our own explicit initializer.A property wrapper alternativeAn alternative approach to the above, which may be something we want to consider if we want to deploy the constants-with-default-values pattern in many places across a larger code base, is to use a property wrapper to make var properties read-only.Since the mutability of a wrapped property depends on the wrapper itself, we could declare a Readonly wrapper type which marks its wrappedValue as a let — like this:@propertyWrapper struct Readonly { let wrappedValue: Value}Since we’re planning to use our Readonly wrapper within types that conform to Encodable and Decodable, then we also need to adopt those protocols for our wrapper as well — since all coding tasks are deferred to property wrappers when they’re used:extension Readonly: Encodable where Value: Encodable { func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() try container.encode(wrappedValue) }}extension Readonly: Decodable where Value: Decodable { init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() wrappedValue = try container.decode(Value.self) }}Above we’re using Swift’s conditional conformances feature to not have to require that all Readonly.Value types always have to conform to Encodable and Decodable, which would limit our property wrapper’s versatility.With the above in place, we can now go ahead and update our User type to use Readonly-marked var declarations for the properties that we want to define a default value for:struct User: Identifiable, Codable { @Readonly var id = UUID() let name: String let bio: String @Readonly var imageURL: URL?}Note that, since we’re now using a var for our type’s imageURL property, it’s default value automatically becomes nil — there’s no need for us to declare that manually.The advantage of the above approach is that we now have a reusable solution that helps us avoid having to manually declare initializers when all we want is to be able to define default values for read-only properties. However, whenever we use a non-standard solution, like the one above, it’s important to consider whether the inherent additional complexity of such a custom solution is worth the benefits that we get from it.Is immutability always the answer?Another alternative approach to make our properties support default values is to simply declare them using var instead. While that does enable those properties to be mutated, perhaps that isn’t actually a problem, given that all structs are by default passed as immutable copies when calling a function or when initializing another type.In practice, that means that structs don’t have the same shared mutable state problem that classes often do (unless static mutable values are used), so the question is how problematic it would actually be to do something like this:struct User: Identifiable, Codable { var id = UUID() var name: String var bio: String var imageURL: URL?}One benefit of making our structs as mutable as possible is that doing so often makes it much easier to write unit tests — either when our structs are used as stub values, or when the structs themselves are the types being tested. For example, let’s say that we wanted to add a method for normalizing a given user’s name:extension User { mutating func normalizeName() { name = name .filter { char in char.isLetter || (char.isWhitespace && !char.isNewline) } .trimmingCharacters(in: .whitespaces) }}Since the name property is now a var, we could easily write a test that verifies various normalization scenarios, all while reusing the same User value — for example like this:struct UserTests { @Test func normalizingName() { var user = User(name: "Name", bio: "Bio") // Non-letter characters are removed: user.name = "!1_First 2;Last_3?" user.normalizeName() #expect(user.name == "First Last") // Leading and trailing whitespaces are removed: user.name = " White Spaces " user.normalizeName() #expect(user.name == "White Spaces") }}It’s also important to remember that just because we mark a given struct property as a let doesn’t mean that its value can never change, since the mutability of a given value is always determined by the top-level, enclosing value that the property is contained within.For example, let’s say that we wanted to make another attempt at striking a nice balance between mutability and consistency for our User type — this time by making all non-id properties variables, while keeping the id property a let (since that’s the one property we never expect to change throughout the lifetime of a User value):struct User: Identifiable, Codable { let id: UUID var name: String var bio: String var imageURL: URL?}Then, let’s say that we wanted to introduce an API for transforming a given User value in some way, for example by using the normalizeName method we defined earlier. Such an API could take the form of a UserTransformer protocol, which uses Swift’s inout parameter feature to enable each transformer to directly mutate the User value that was passed to it, without first having to make a mutable copy:protocol UserTransformer { func transformUser(_ user: inout User)}struct UserNameNormalizer: UserTransformer { func transformUser(_ user: inout User) { user.normalizeName() }}So the question is, with the above setup, do we have a guarantee that the id property of any User value that was passed to a UserTransformer implementation can never be changed? No, actually, we don’t. Because we have to remember, the User value itself is mutable, and it can be completely re-assigned with a brand new UUID if the implementation so desires — for example like this:struct UserIDTransformer: UserTransformer { func transformUser(_ user: inout User) { user = User( id: UUID(), name: user.name, bio: user.bio, imageURL: user.imageURL ) }}Admittedly, code like the above is quite likely to raise some eyebrows during code review (when working with a team), but it just illustrates how we have to think about value types — such as structs and enums — when working with them. They don’t have the same concept of identity and a lifecycle, like classes and actors do, which is important to remember when we design our types and their associated APIs.ConclusionSo how do you decide between using let and var when declaring struct properties? My personal approach is to keep my struct properties mutable by default, since I feel like that really leans into the core concept of value types — that it’s the enclosing value, not individual properties, that actually determines the real mutability of a given value.That being said, marking properties that we never expect to be mutated (such as a type’s ID) as let is also usually a good practice — even though it doesn’t strictly guarantee that such values will never change, it at least signals to everyone on the team what the intended mutability of such a property is.What do you think? Let me know what your thoughts are on this topic — along with any questions or feedback you might have — on either Mastodon or Bluesky.Thanks for reading!