[Pitch] Memberwise reinitialization for let properties of struct when the synthesized memberwise initializer is available

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:

  1. base is a mutable lvalue (e.g., a var variable, an inout parameter, a writable property, an array element, etc.).
  2. p is a let stored (non-static) property of struct T.
  3. The synthesized memberwise initializer is accessible in the current scope.

When It Is Forbidden

  1. If the memberwise initializer is not synthesized (due to a custom init).
  2. 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 let and 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 let properties of classes.

  • It is not proposed to allow adding didSet/willSet observers to let properties. 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), then base.p = ... for a let p must not compile either.

  • If the type is public but its memberwise initializer is not public, such assignment must not be allowed outside the module — this helps preserve the resilience/ABI model of public types.

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' constant will 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 @frozen structs 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.

I do think it's sound—the usual concern would be "what if there's an init that enforces preconditions" and the memberwise init doesn't—but I still have some concerns:

  • It won't ever work across module boundaries. Even if we get a way to request public memberwise initializers from the compiler, I don't think we'd ever want it to have different behavior from just writing it yourself.

  • There's a better way to get this behavior, and it's "use var for the properties". Sure, it's more verbose if you need to write internal(set) etc, but it is exactly the semantics anyone would expect, without appealing to any other declarations.

I'd rather have people learn that "let everywhere" is too simple a rule than try to work around that to accommodate them. Maybe we shouldn't have reused the same keywords for struct properties and class properties (and neither for enum payloads?), because they don't quite have the same semantics. But we did.

25 Likes

My only problem with this, is that the compiler won't let you mark a public internel(set) variable as @usableFromInline.

Strong no. We already have all we need to manage immutability and mutability.

1 Like

I'd contend that this is emphatically not correct (though it is common) and in every codebase I've worked in I've pushed for the policy to be structs should default to var unless there's a good reason otherwise, classes should default to let unless there's a good reason otherwise.

15 Likes

Colleagues, thank you very much for the feedback!

I agree that the different behavior between the synthesized init and a manually written one would be confusing. And I have exactly the same story with attempts to add a "var by default in structs" rule to the code style guide — in my experience, developers tend to resist it. It would be great if the language design itself pointed in the right direction.

The comment about enum payloads sparked an idea for an alternative 7. What if we removed the need to explicitly write the var/let keyword in structs by making var the default? This would differentiate structs from classes while, conversely, emphasizing their "value type" kinship with enums, where associated values don't require var/let to be specified. What do you think? It seems like this would warrant a separate pitch.

It is more explicit, but you can probably solve most of the original issue using macro.

A struct marked as derivable (or anything else), may have a generated method that takes as many arguments as the count of field, as Optional and defaulting to null.

struct Point {
  let x: Int;
  let y: Int;
  let z: Int;

  // Generated by a macro
  func derived(x: Int? = nil, y: Int? = nil z: Int? = nil): Point {
    return Point(x: x ?? self.x, y: y ?? self.y, z: z ?? self.z)
  }
}

and also for local(global) variables...

-1 for the pitch – just change lets to vars (I do this on "as needed" basis), or use a noisy p = Point(x: 3, y: p.y, z: p.z) version if there's semantic difference.

3 Likes