Hello Swift community, I would like to propose a solution to the question "how should properties in structures be declared — via let or var?".
Please, before drawing conclusions, make sure you have read the proposal thoroughly, in particular the section What is NOT proposed (in this pitch).
Introduction
Most developers follow the mental model of "make let, not var", which is correct — but in the case of struct properties, this creates a problem.
In Swift, a stored property declared as let cannot be mutated:
struct Point {
let x: Int
let y: Int
let z: Int
}
var p = Point(x: 1, y: 2, z: 3)
p.x = 3 // Error
p = Point(x: 3, y: p.y, z: p.z) // OK
However, if the instance itself is declared with var, we can replace it with a new value. If we provide a new value only for the desired property while copying the rest from the current instance, this is semantically equivalent to mutation — despite the property being declared with let.
Proposal: allow assignment of the form p.x = ... for let stored properties, in exactly those contexts where the synthesized memberwise initializer is available — so that the line p.x = 3 in the example above would no longer be an error.
Motivation
Ergonomics
A common pattern: a struct holds many invariant (let) fields and a few mutable ones, or all fields are let, but the instance is stored in a var and occasionally one property needs to be updated.
Today, developers are forced to write noisy code:
p = Point(x: 3, y: p.y, z: p.z) // semantically means mutation
or introduce custom with/copy methods that essentially duplicate the memberwise initializer.
Reducing boilerplate and copy-paste errors
The more fields a struct has, the greater the risk of incorrectly carrying over one of them when manually reconstructing the instance.
Performance
Direct mutation is more efficient than calling an initializer. The compiler eliminates the init call in most cases today. Nevertheless, here is a small example demonstrating that when a property is declared with let and the instance is recreated via init, the compiler generates n − 1 extra instructions compared to a var property with direct mutation, where n is the number of fields in the struct.
Proof — godbolt link: Compiler Explorer
struct ExLet {
let a: Int
let b: Int
let c: Int
let d: Int
}
struct ExVar {
var a: Int
var b: Int
var c: Int
var d: Int
}
var exLet = ExLet(
a: 12,
b: 8,
c: 8,
d: 8,
)
// makes 4 MOV instead of 1 at line 36
exLet = ExLet(
a: 1234,
b: exLet.b,
c: exLet.c,
d: exLet.d,
)
var exVar = ExVar(
a: 56,
b: 9,
c: 9,
d: 9,
)
exVar.a = 5678 // line 36
Proposed solution
Allow assignment to a let stored property of a struct in exactly those contexts where the synthesized memberwise initializer is available.
Example:
var p = Point(x: 1, y: 2, z: 3)
The line:
p.x = 3
is semantically equivalent to:
p = Point(x: 3, y: p.y, z: p.z)
Detailed Design
When It Is Allowed
Let there be an assignment expression:
base.path = newValue
where path refers to a stored property p of struct T, declared as let.
The assignment is permitted if all of the following conditions hold:
baseis a mutable lvalue (e.g., avarvariable, aninoutparameter, a writable property, an array element, etc.).pis a let stored (non-static) property of struct T.- The synthesized memberwise initializer is accessible in the current scope.
When It Is Forbidden
- If the memberwise initializer is not synthesized (due to a custom
init). - If the memberwise initializer has a lower access level than the calling context.
Complex Cases
Nested struct:
struct Outer { let p: Point }
var o = Outer(p: Point(x: 1, y: 2, z: 3))
o.p.x = 10
// equivalent to
o.p = Point(x: 10, y: o.p.y, z: o.p.z)
// which in turn is equivalent to
o = Outer(p: Point(x: 10, y: o.p.y, z: o.p.z))
Property observers:
struct Outer {
var p: Point {
willSet {
print("willSet")
}
}
}
var o = Outer(p: Point(x: 1, y: 2, z: 3))
o.p.x = 10 // "willSet" is printed, just as it would be with a full re-initialization
Array:
var arr = [Point(x: 1, y: 2, z: 3)]
arr[0].x = 5
// equivalent to
arr[0] = Point(x: 5, y: arr[0].y, z: arr[0].z)
// following the usual rules for array element access
Passing as an inout argument:
var p = Point(x: 1, y: 2, z: 3)
foo(&p.x)
// equivalent to:
var t = p.x
foo(&t)
p.x = t // which in turn is equivalent to
p = Point(x: t, y: p.y, z: p.z)
Compound assignment operators
If simple assignment is permitted, it is natural to also permit compound read-modify-write forms:
p.x += 1
is equivalent to:
let t = p.x + 1
p.x = t
(preserving the standard rules for evaluation order and exclusive access.)
What Is Not Proposed (in this pitch)
-
It is not proposed to break the immutability and invariants of structs.
-
It is not proposed to allow mutation when an instance is declared via
letand is not a property of another struct. -
It is not proposed to allow mutation when the memberwise initializer is written manually or generated by a macro instead of being synthesized (because in that case we cannot verify the absence of side effects).
-
It is not proposed to change the rules for
letproperties of classes. -
It is not proposed to allow adding
didSet/willSetobservers toletproperties. At the same time, property observers on the outer struct must still fire as before when the inner struct is re-initialized via the memberwise initializer.
This proposal does not suggest violating the principle "let means immutable" — rather, it demonstrates that mutating a struct and recreating it are equivalent operations when the synthesized memberwise initializer is available and has no side effects.
Access Control / Memberwise Initializer Availability
The key constraint of the proposal: the operation is permitted only in scopes where, under the current rules, the replacement could be expressed via the memberwise initializer without violating access control.
Intuitively:
-
If you cannot write
base = T(...)from a given scope (because the memberwise initializer is not accessible), thenbase.p = ...for alet pmust not compile either. -
If the type is
publicbut its memberwise initializer is notpublic, such assignment must not be allowed outside the module — this helps preserve the resilience/ABI model ofpublictypes.
Performance Benefits
The proposal enables the generation of more performant code (see the example above in the Motivation section).
Symmetry
This proposal introduces a special case in which a let property can be mutated directly. On the other hand, it also adds symmetry of a different kind:
Currently, var fields cannot be changed when the instance is declared via let:
struct ExVar {
var a: Int
}
let exVar = ExVar(a: 0)
exVar.a = 1 // Error
This proposal introduces a symmetric capability — the ability to change let fields when the instance is declared via var:
struct ExLet {
let a: Int
}
var exLet = ExLet(a: 0)
exLet.a = 1 // Currently an error, but should be OK under this proposal
Thus, the general rule for structs with an accessible synthesized memberwise initializer would be: mutability is determined externally by the instance declaration, not internally by the property declaration. This is in contrast to the current rule, where immutability can be enforced both externally (via a let instance) and internally (via a let property) — which obscures the fact that a let property can already be changed through the memberwise initializer.
Swift does not have a protected access level because it does not guarantee restricted access (it can always be bypassed from a subclass). Similarly, let fields in structs do not actually close off the possibility of mutation in scopes where the synthesized memberwise initializer is available.
Tooling Interaction
- SourceKit / IDE: The diagnostic
cannot assign to property: 'x' is a 'let' constantwill be suppressed in permitted cases. In impermissible cases, the error message can be made more precise:memberwise re-initialization is not available in this scope.
Source Compatibility
The change is strictly additive
The proposal does not break existing code: everything that compiled before continues to compile with the same semantics. The new behavior is enabled only in places that previously produced a compilation error (cannot assign to property: 'p' is a 'let' constant).
| Before | After |
|---|---|
| Code that compiled | still compiles, semantics unchanged |
| Code that did not compile | now compiles (new case) |
This means zero regression risk for existing projects and packages.
Potential concern: inadvertent unlocking of previously rejected code
In theory, existing code might have intentionally relied on the compilation error as a protective barrier:
struct Config {
let endpoint: URL // "let the compiler prevent accidental mutation"
}
var config = Config(endpoint: someURL)
config.endpoint = otherURL // previously an error, now OK
The author of such code may have used let as a "soft" guard even on a var instance. After this proposal is accepted, that barrier disappears.
However, this does not constitute a breaking change in the strict sense: no previously compiled binary changes its runtime behavior. This is merely a semantic shift in developer intent, not in runtime behavior.
If a developer needs genuine protection against external mutation of a stored property, the correct tool is a custom init. Nevertheless, for such cases an opt-out annotation could also be introduced:
// Possible opt-out — explicit reassignment prohibition
@noreassign let endpoint: URL
ABI Stability
The proposal introduces no new ABI symbols and does not alter calling conventions.
- The change is confined to the type checker only.
- No new witness table entries.
- No impact on
@frozenstructs in resilient libraries.
API Resilience
- The proposal is intentionally tied to the availability of the memberwise initializer so as not to degrade resilience.
- The proposal does not change the rules for adding stored properties to public types.
Alternatives Considered
1) Require an explicit "copy initializer", e.g. init(_ other: Self, updating: ...).
- Pro: explicit API and full author control.
- Con: boilerplate; does not address the fundamental ergonomic pain without additional library support or macros.
A language-level solution is preferable to a macro-based one (uniformity, no imports required, compiler-native diagnostics).
2) Introduce new syntax such as base = base.with(.p, newValue)
- More explicitly signals "we are creating a copy".
- However, this either requires a new language feature (key-path update) or a library/macro layer, and is still less natural than a plain assignment.
3) Allow assignment to let properties unconditionally.
This would destroy the meaning of let as part of a type's data model and could interact with addressability and inout too broadly.
Example:
struct Validated {
let value: Int // invariant: value >= 0
init?(value: Int) {
guard value >= 0 else { return nil }
self.value = value
}
}
var x = Validated(5)!
x.value = -1 // unconstrained — invariant violated
The proposal deliberately scopes the change: only cases that are equivalent to an allowed full re-initialization are affected.
4) Change nothing and use var properties instead.
Does not work because of the established mental model of "make let, not var". This should be addressed at the language design level.
5) A macro for structs that adds a memberwise initializer and rewrites let fields as var.
It is not clear to users why assignment to a let field works.
6) Emit a warning when let fields are declared alongside a synthesized memberwise initializer, suggesting replacement with a var of the corresponding access level.
A warning does not solve the problem; it merely shifts the burden to the user. The goal of the proposal is to make let semantically correct in a memberwise context, not to warn about its "incorrect" use.
7) Allow stored properties to be declared without a let/var keyword, treating them as var by default.
This is not symmetric with respect to fields/methods in classes and protocols.
Future Directions
1) Mutation via private(set)
This case is indeed symmetric to the main proposal:
struct S {
private(set) var a: Int
var b: Int
}
var s = S(a: 0, b: 0)
s.a = 1 // error: setter inaccessible
s = S(a: 1, b: s.b) // OK — full reinitialization is allowed
The reasoning is the same: if whole-value reinitialization via memberwise init is permitted, then the equivalent dot-syntax mutation adds no new capabilities semantically.
However, this involves a different mechanism (access control) rather than let-semantics.
2) Memberwise init annotation
It is worth considering an annotation for synthesizing a memberwise initializer with an explicit access level (currently, the access level of the synthesized init is inferred from the access levels of the properties). This would make it possible to synthesize a public memberwise initializer. Unlike a macro that achieves the same result, an annotation would allow the compiler to skip checking for side effects in the init after macro expansion.