[Pitch] Property wrappers on let declarations with reference-backed setters

[Pitch] Property wrappers on let declarations with reference-backed setters

Introduction

This proposal allows a property wrapper to be applied to a let declaration. The synthesized backing storage becomes a let, so it is immutable after initialization. When the wrapper type's wrappedValue has a nonmutating set, the wrapped property keeps a setter that forwards to the wrapper.

The result is that thread-safe, reference-backed property wrappers can be used in Sendable types, on nonisolated members, as static members, and in @Sendable closures under Swift 6 strict concurrency checking. This does not require @unchecked Sendable, global-actor isolation, or hand-written boilerplate.

Motivation

Swift 6 rejects thread-safe wrappers in Sendable types

A property wrapper always synthesizes a stored var for its backing storage. Consider a wrapper that is fully thread-safe and Sendable:

import os

@propertyWrapper
public struct Locked<Value: Sendable>: Sendable {
    private let lock: OSAllocatedUnfairLock<Value>

    public init(wrappedValue: Value) {
        lock = OSAllocatedUnfairLock(initialState: wrappedValue)
    }

    public var wrappedValue: Value {
        get { lock.withLock { $0 } }
        nonmutating set { lock.withLock { $0 = newValue } }
    }
}

OSAllocatedUnfairLock is a copyable struct that owns heap-allocated lock storage, so copies of the wrapper share the same state. That is why wrappedValue can offer a nonmutating set. The example uses an Apple-platform API for brevity. Any Sendable lock with reference semantics works the same way; on other platforms, Synchronization.Mutex stored in a final class box is one option.

Using this wrapper in a Sendable class is still an error:

final class Counter: Sendable {
    @Locked var count = 0
    // error: stored property '_count' of 'Sendable'-conforming class 'Counter' is mutable
}

The error is about the synthesized var _count. It is not about the wrapper's behavior. Nothing ever needs to reassign _count, because all mutation goes through the wrapper's nonmutating set.

Today, developers pick one of these workarounds:

  • @unchecked Sendable on the enclosing type. This turns off checking for every stored property, not only the wrapped one.
  • Isolating the type to @MainActor or another global actor. This changes the API and forces await at call sites, even though the wrapper already synchronizes access.
  • Replacing the wrapper with a macro. This means writing and maintaining a macro target for what is conceptually a property wrapper.
  • Writing the expansion by hand.

The hand-written expansion is the telling one. It is accepted by the compiler today, with full Sendable checking:

final class Counter: Sendable {
    private let _count = Locked(wrappedValue: 0)
    var count: Int {
        get { _count.wrappedValue }
        set { _count.wrappedValue = newValue }
    }
}

So the language already considers this pattern safe. The only thing missing is a way to spell it with the property wrapper attribute.

nonisolated wrapped properties

For the same reason, nonisolated is not allowed on wrapped properties:

@MainActor
final class ViewModel {
    @Locked nonisolated var requestCount = 0
    // error: 'nonisolated' is not supported on properties with property wrappers
}

A let backing storage of a Sendable type is exactly what nonisolated requires.

Static and global wrapped properties

@Locked static var cache = [String: Data]() is diagnosed as nonisolated global mutable state in Swift 6, even though the wrapper serializes all access. A static let of a Sendable type is already concurrency-safe.

Local wrapped properties in @Sendable closures

@Locked var progress = 0
await withTaskGroup(of: Void.self) { group in
    for _ in 0..<10 {
        group.addTask { progress += 1 }
        // error: mutation of captured var in concurrently-executing code
    }
}

The capture is the synthesized local var _progress. With let backing storage, the closure captures a Sendable constant instead.

Proposed solution

Allow property wrapper attributes on let declarations:

final class Counter: Sendable {
    @Locked let count = 0
}

let counter = Counter()
counter.count = 5          // OK: forwards to Locked.wrappedValue's nonmutating set
print(counter.count)       // 5

The declaration expands to:

private let _count: Locked<Int> = Locked(wrappedValue: 0)
var count: Int {
    get { _count.wrappedValue }
    set { _count.wrappedValue = newValue }   // `nonmutating set` in value types
}

When the wrapper's wrappedValue has a mutating setter, or no setter at all, no setter is synthesized, and the wrapped property is read-only:

@propertyWrapper
struct Clamped { /* wrappedValue with a mutating set */ }

struct Color {
    @Clamped(0...255) let red: Int = 300
}
color.red = 10   // error: cannot assign to property: 'red' is declared with 'let'
                 // note: 'Clamped.wrappedValue' has a mutating setter

Detailed design

Backing storage

For @W let x, the compiler synthesizes the backing storage as private let _x: W. It has the same access level and naming rules as SE-0258. The backing storage can be initialized exactly once, and assigning to _x afterwards is an error, as with any let.

Getter

The synthesized getter reads _x.wrappedValue. If W.wrappedValue has a mutating get, applying W to a let is an error. The diagnostic suggests changing the declaration to var.

Setter

A setter is synthesized only if W.wrappedValue declares a nonmutating set. The setter is:

  • nonmutating set when the enclosing type is a value type;
  • a plain set in classes and actors, where setters never mutate the reference;
  • for local and global declarations, a setter on the synthesized computed variable.

A _modify coroutine is synthesized from the getter and setter, in the same way as for nonmutating computed properties today.

Projected value

If W has a projectedValue, $x is synthesized with the same rules. It gets a getter if the projectedValue getter is non-mutating, and a setter only if the projectedValue setter is nonmutating.

Initialization

The rules follow SE-0258 and SE-0400, with let constraints:

  • With an initial value (@W let x = v), _x is initialized with W(wrappedValue: v).
  • Without an initial value, the first assignment to x in an initializer initializes _x through init(wrappedValue:). This is determined by definite initialization analysis, as it is for var wrapped properties today. Later assignments in the same initializer call the synthesized setter if there is one. Otherwise they are an error.
  • @W(arg) let x and explicit _x = W(...) in an initializer are supported as for var, but only once.

Concurrency checking

No new Sendable rules are introduced. The backing storage is an ordinary let stored property:

  • A Sendable class or struct requires W: Sendable. Conditional conformances apply as usual, for example Locked<Value>: Sendable where Value: Sendable.
  • nonisolated is allowed on a let wrapped property when W: Sendable, under the existing rules for nonisolated let.
  • static let and global let wrapped properties are concurrency-safe when W: Sendable.
  • A local let wrapped property can be captured by @Sendable or sending closures when W: Sendable.

Compound assignments are not atomic

counter.count += 1 expands to a get followed by a set, exactly as with the hand-written expansion today. This is not a data race: all storage access is synchronized by the wrapper. However, it can lose updates under contention. This proposal does not change this behavior, and it is out of scope. Wrappers that need atomic read-modify-write should expose it explicitly, for example $count.withLock { $0 += 1 } through a projected value.

Module interfaces

In .swiftinterface files, a let wrapped property is printed as the computed property it expands to. That is var x: T { get } or var x: T { get set }, matching how the compiler already treats it, together with the wrapper attribute where SE-0258 prints it today.

Accessor macros

In response to Doug Gregor's feedback in the 2022 pitch thread, this proposal applies the same rule to accessor macros (SE-0389) attached to let declarations:

  • The macro must produce a getter. It must also produce an init accessor (SE-0400) when the property has no initial value.
  • A setter produced by the macro is kept only if it is nonmutating, or if the enclosing type is a class or actor. Otherwise it is dropped, and assignment is diagnosed.
  • willSet, didSet, and mutating get are not allowed.

This keeps property wrappers and accessor macros aligned. Property-wrapper-like macros can then adopt let in the same way.

The meaning of let

The main concern raised in earlier discussions is that let would no longer guarantee that the value you read never changes.

This proposal keeps the guarantee that Swift gives for let bindings: the backing storage can never be reassigned. It does not strengthen the guarantee about the value observed through that storage. Swift already makes this distinction today:

let state = SomeStruct()      // SomeStruct.value has `nonmutating set`
state.value = 42              // already legal

let object = SomeClass()
object.property = 42          // already legal

A wrapped let property is only settable when the wrapper author has explicitly declared nonmutating set, which already states "this type has reference semantics." Wrappers with value semantics get a read-only property, so the let behaves as developers expect.

We also considered requiring getters on let declarations to be pure. That would be a much larger language feature, and it is listed under Future directions.

Source compatibility

This is purely additive. Existing var wrapped properties are unchanged.

ABI compatibility

The storage layout is unchanged: a let backing property has the same layout as a var one. No runtime support is required, and the feature can be back-deployed.

Implications on adoption

Changing a public wrapped property between var and let changes whether _x can be reassigned inside the declaring type. It may also remove the setter when the wrapper's setter is mutating. Library authors should treat this change like any other change to a property's settability.

Alternatives considered

let always produces a read-only property

This was the revised design of the 2022 pitch. It does not address the concurrency use cases above, because they need to write through a Sendable wrapper.

Keep the var spelling and infer let storage

The compiler could automatically synthesize let _x when W.wrappedValue has a nonmutating set. This would be source-breaking for code that reassigns _x after initialization. It would also make the storage mutability invisible at the declaration site.

An explicit storage modifier, e.g. @W(storage: let) var x

This avoids the question about the meaning of let, but it adds new syntax for something that let already expresses: an immutable binding for the backing storage. We are open to this spelling if the community prefers it.

Support nonisolated(unsafe) on wrapped properties

This would unblock some cases, but it gives up checking entirely. This proposal keeps full Sendable checking.

Use macros instead

Macros can model this today, but they require a separate macro target, a compiler plugin, and more complex tooling for a common pattern. The accessor macro section above makes both mechanisms consistent instead of forcing a migration.

Init wrappers

The "[Pitch] Init Wrappers" thread (2026) addresses initialization-time dependency injection. It is complementary to this proposal: that pitch covers how a value is constructed, while this one covers how a wrapped property is stored and accessed.

Future directions

  • let requirements in protocols, satisfiable by wrapped let properties.
  • Pure or "stable" getters, allowing the compiler to verify that a let wrapped property observes a constant value when the wrapper has value semantics.
  • Noncopyable wrapper types, so that a wrapper could store Synchronization.Mutex inline, without a class box, on all platforms.

Acknowledgments

Thanks to Amritpan Kaur and Pavel Yaskevich for the original pitch and implementation (swiftlang/swift#62342). Thanks to Holly Borla for identifying the nonisolated motivation, and to Doug Gregor for the feedback on let semantics and accessor macros that shaped this design.

9 Likes

No notes from me. I just wanted to thank you for such a well-written pitch.

Aside from the proposal itself, you really shouldn't repeat the Locked wrapper pattern, as it's fundamentally unsafe. It took a while to get it out of codebases after the original property wrapper proposal popularized it, as it's fundamentally unsafe when accessing the properties of the wrapped values. So I would suggest coming up with a different motivating example so this wrapper doesn't become popular again.

7 Likes