[Pitch] Allow Property Wrappers on Let Declarations

Hello everyone!

The following is a pitch for allowing let declared property wrappers in the Swift language, a topic that has come up in discussions here on the Swift forums. We welcome any feedback or questions about this pitch!


Allow Property Wrappers on Let Declarations

Authors: Amritpan Kaur, Pavel Yaskevich
Status: Implemented
Implementation: [Sema/SILGen] Allow property wrappers on `let` declarations by amritpan · Pull Request #62342 · apple/swift · GitHub

Introduction

SE-0258 Property Wrappers models both mutable and immutable variants of a property wrapper but does not allow wrapped properties explicitly marked as a let. This proposal builds on the original vision by expanding property wrapper usage to include let declared properties with a wrapper attribute.

Motivation

Allowing property wrappers to be applied to let properties improves Swift language consistency and code safety.

Today, a property wrapper can be applied to a property of any type but the rules for declaring these wrapped properties are varied and singular to property wrappers. For example, a struct instance without the property wrapper attribute can be declared with either a var or a let property. However, mark the struct as a property wrapper type and the compiler no longer allows it to be written as a let wrapped property:

@propertyWrapper
struct Wrapper {
  var wrappedValue: Int { 0 }
  init(wrappedValue: Int) {}
}

struct S {
  @Wrapper let value: Int // Error: Property wrapper can only be applied to a ‘var’
}

Permitting wrapped properties to mimic the rules for other type instances that can be written with either a var or a let will simplify the Swift language.

Additionally, let wrapped properties add code safety where a user wants to expressly remove access to a property’s mutators after initializing the property once or simply does not need a mutable property wrapper. This could be useful for property wrappers that do not change or are reference types.

Proposed solution

We propose to allow the application of property wrappers to let declared properties, which will permit the wrapper type to be initialized only once without affecting the implementation of the backing storage.

For example, TSPL defines a SmallNumber property wrapper and applies it to UnitRectangle properties:

@propertyWrapper
struct SmallNumber {
  private var maximum: Int
  private var number: Int

  var wrappedValue: Int {
    get { return number }
    set { number = min(newValue, maximum) }
  }

  init(wrappedValue: Int) {
    maximum = 12
    number = min(wrappedValue, maximum)
  }
}

struct UnitRectangle {
  @SmallNumber var height: Int = 1
  @SmallNumber var width: Int = 1
}

Initial values for height and width are set using the init(wrappedValue:) initializer of the SmallNumber property wrapper type. To ensure that height and width are not changed again, we could add logic to the wrappedValue setter to check if the property was already initialized and prevent re-assignment. However, this is an inconvenient solution.

Instead, we could declare these properties with a let, synthesize a local let constant for the backing storage property (prefixed with an underscore), and only allow the property wrapper to be initialized once, passing the assigned value to init(wrappedValue:). Now, rewriting UnitRectangle’s properties as let constants will translate to:

private let _height: SmallNumber = SmallNumber(wrappedValue: 1)
var height: Int {
  get { return _height.wrappedValue }
}

private let _weight: SmallNumber = SmallNumber(wrappedValue: 1)
var weight: Int {
  get { return _weight.wrappedValue }
}

and results in code that is easy to write and understand:

struct UnitRectangle {
  @SmallNumber let height: Int = 1
  @SmallNumber let width: Int = 1
}

Property wrappers with let declarations will be allowed both as members and local declarations, as envisioned by SE-0258 for var declared property wrappers. All other property wrapper traits also remain unchanged from SE-0258.

Property wrappers with nonmutating set

Property wrappers that have a wrappedValue property with a nonmutating set (e.g., SwiftUI's @State and @Binding) will preserve the reference semantics of the wrappedValue implementation even when marked as a let declaration. For example:

@State let weekday: String = "Monday"

Here, weekday is an immutable instance of the @State property wrapper, but its wrappedValue storage will retain its mutability and reference type traits. This will translate to:

private let _weekday: State<String> = State<String>(wrappedValue: "Monday")
var weekday : String {
  get { 
    return _weekday.wrappedValue 
  }
  nonmutating set {
    self._weekday.wrappedValue = value
  }
  nonmutating _modify { 
    yield () 
  }
}
var $weekday: Binding<String> {
  get { return _weekday.projectedValue }
}

Detailed design

Here are three examples of how a let wrapped property can make current iterations more effortless.

The Clamping property wrapper from SE-0258 Property Wrappers can be rewritten to use let as its storage and implementation do not change, no matter its application.

struct Color {
  @Clamping(min: 0, max: 255) let red: Int = 127
}

A let wrapped property could be useful for reference types like a property wrapper class. Typically property wrappers are written for value types but occasionally a protocol like NSObject may require the use of a class. For example:

@propertyWrapper
class Wrapper<T> : NSObject {
  var wrappedValue: T

  init(wrappedValue: T) {
    self.wrappedValue = wrappedValue
  }
}

class C {
  @Wrapper let value: Int

  init(v: Int) {
    value = v
  }
}

can be made an immutable property wrapper class instance, preventing any future unintentional changes to the property wrapper class type in this context.

SwiftUI property wrappers may also benefit from a let declaration. For example, @ScaledMetric in its simplest usage can be written with a let instead:

struct ContentView: View {
  @ScaledMetric let imageSize = 10

  var body: some View {
    Image(systemName: "heart.fill")
    .resizable()
    .frame(width: imageSize, height: imageSize)
  }
}

Similarly, other SwiftUI property wrappers could be let declared when they do not require more than a single initialization.

Source compatibility

This is an additive feature that does not impact source compatibility.

Effect on ABI stability/API resilience

This is an additive change that has no direct impact that compromises ABI stability.

Effect on API resilience

This is an additive change that has no impact on API resilience.

22 Likes

This is not compatible with the expansion to let _x: DelayedImmutable<Int>. That’s still a stored property that has to be initialized in the containing type’s init (whether by wrapped value or not), and then never modified again (because let does not allow mutation).

EDIT: and stepping back, what happens if I call initializeX twice?

1 Like

Toolchains:

Very nice, this has been an annoying inconsistency.

Could you please double-check if things work well with this and @TaskLocal or if there's some extra work we need to do there? Apple Developer Documentation

Actually, in the docs we even have...

enum TracingExample {
    @TaskLocal
    static let traceID: TraceID?
}

which is wrong (and docs were fixed), since this must be a var nowadays. So, will this actually resolve this issue and such let is now possible? (Would be good to add some tests for this explicitly perhaps in async_task_locals_basic.swift).

4 Likes

As much as I like the concept in general, it seems like it can't be used very often. For example, could SceneStorage really be used like this? How can the compiler know that the underlying value won't be mutated? The brute force method would be to disallow property wrappers with computed getters, or only allow them when it can be proven that they yield the same value on every access.

As far as I can see the return value needs to be calculated when the property wrapper is initialised, which would seem to severely limit the utility.

I have some issues understanding the pitch, so here's my feedback:

This synthesis does not make much sense to me. Where does the 1 in let height: Int = 1 come from?
Property wrappers aren't magic, they are pure syntactic transformations.

You say this, but I'm having a feeling there is more to this.

Initial values for height and width are set using the init(wrappedValue:) initializer of the SmallNumber property wrapper type.

What does the transformation exactly look like in the above case?

init(...) {
  let heightWrapper = SmallNumber(wrappedValue: 1)
  // you have to read the value from somewhere!?
  self.height = heightWrapper.wrappedValue 
  self._height = heightWrapper
  ...
}

I expect a let property to not be a computed property, therefore there is no way to access the property wrapper through the wrapper property anymore except for going the direct _height route.


How does this affect projectedValue properties?


This example makes even less sense to me.

  1. get { return value } what is value and where does it come from?
  2. set { _value = newValue } what is the setter for? It's totally useless on a wrapper that itself becomes a constant as per your initial example with SmallNumber.
  3. How is this transformation technically possible without introducing some kind of laziness?
@DelayedImmutable let x: Int

init() {
  // We don't know "x" yet, and we don't have to set it
}
  1. How can you even assign to x when the property wrapper itself is a let? Again the syntactic transformation isn't showed in your examples nor properly explained.

I have hard times understanding how DelayedImmutable can be a constant. If anything it should be lazy let.


Okay class Wrapper<T> can become an immutable reference, but how useful it it? What will you do with the single time initialized wrappedValue, which also just a duplicate?!


Please patch the indentation in your code samples.


How about the let marker on a property rather being a projection onto the wrapper's underscored property? If the wrapper has a nonmutating set on wrappedValue and projectedValue, those will be exposed through the computed property, which will still remain a var. In other cases it will be excluded from the computed property.

In SwiftUI I'd mark all @State properties with let.

An example of that idea:

@propertyWrapper
struct A<V> {
  var wrapperValue: V {
    get { ... }
    nonmutating set { ... }
  }
}

@A let p1: Int = 1
// translates to
let _p1: A<Int> = A(wrappedValue: 1)
var p1: Int {
  get { _p1.wrappedValue }
  nonmutating set { _p1.wrappedValue = newValue }
}
@propertyWrapper
struct B<V> {
  var wrapperValue: V {
    get { ... }
    set { ... }
  }
}

@B let p2: Int = 2
// translates to
let _p2: B<Int> = B(wrappedValue: 2)
var p2: Int {
  get { _p1.wrappedValue }
}

Same applies to projectedValue and the $ prefixed projections.

1 Like

Based on what I said at the end here's a potential real world example. Let's look at Clamped PW.

@propertyWrapper 
struct Clamped<Value> where Value: Comparable {
  var _value: Value
  var _range: ClosedRange<Value>

  init(wrappedValue: Value, range: ClosedRange<Value>) {
    self._value = min(max(range.lowerBound, wrappedValue), range.upperBound)
    self._range = range
  }
    
  var wrappedValue: Value {
    get { value }
    set { value = min(max(range.lowerBound, newValue), range.upperBound) }
  }
}

Here's how I imagine it to transform:

// example with VAR
@Clampped(range: 1 ... 10)
var a: Int = 0
// `var` translates to
var _a = Clampped(wrappedValue: 0, range: 1 ... 10)
var a: Int {
  get { _a.wrappedValue }
  set { _a.wrappedValue = newValue }
}
// `a` returns 1 initially and can mutate as expected

// example with LET
@Clampped(range: 1 ... 10)
let b: Int = 0
// `let` translates to
let _b = Clampped(wrappedValue: 0, range: 1 ... 10)
var b: Int {
  get { _b.wrappedValue }
}
// `b` returns 1 initially but cannot be mutated as the PW is immutable and the
// setter from the commuted property `b` is stripped is it's `mutating` in this case.

IMHO the computed property cannot go anywhere and it still should look up the value from the property wrapper itself. Transforming the property wrapper into a constant based on the let vs var keyword declaration seems totally reasonable to me.


Using SwiftUI's State next:

@State
let value: String = "swift"
// translates to 
let _value = State(wrappedValue: "swift")
var value: String {
  get { _value.wrappedValue }
  // nonmutating set remains valid
  nonmutating set { _value.wrappedValue = newValue }
}
1 Like

Thank you for catching this. You're right - this example is an overreach. Instead, the immutable variant that allows for a single initialization should be:

@propertyWrapper
struct DelayedImmutable<Value> {
  var wrappedValue: Value

  init(wrappedValue: Value) {
    self.wrappedValue = wrappedValue
  }
}

class Foo {
  @DelayedImmutable let x: Int

  init(x: Int) {
    self.x = x
  }
}

let c = Foo(x: 10)

And the multiphase initialization in SE-0258’s example would not be possible here because the wrapped property is a let. I will update the pitch to reflect this.

1 Like

Thank you for this feedback. The let wrapped properties transformation has some resemblance to what you've described for Clampped. I will update the pitch to make this clearer as well.

For UnitRectangle with let wrapped height and width, the transformation with the current implementation is:

internal struct UnitRectangle {
  @SmallNumber internal let height: Int {
    get {
      return self._height.wrappedValue
    }
  }
  private let _height: SmallNumber = SmallNumber(wrappedValue: 1)
  @SmallNumber internal let width: Int {
    get {
      return self._width.wrappedValue
    }
  }
  private let _width: SmallNumber = SmallNumber(wrappedValue: 1)
  internal init() {}
}

where 1 is assigned via SmallNumber's wrappedValue init:

_height = SmallNumber(wrappedValue: 1)
_width = SmallNumber(wrappedValue: 1)
1 Like

Is this some kind of a typo, because this is illegal code in Swift?! let properties cannot have a computed getter. Furthermore, why is there a property wrapper at this position still?

How so? This is illegal. You cannot extract the wrappedValue from the init and assign it to the main wrapped properly unless it's inout or something.

A let x declaration is (among other things) a guarantee to the reader that x will have the same value for its lifetime. I don't like that this pitch removes that guarantee:

@propertyWrapper
struct Random {
    var wrappedValue: UInt64 {
        var rng = SystemRandomNumberGenerator()
        return rng.next()
    }
}

struct MyType {
    @Random let r
}

let t = MyType()
print(t.r)
print(t.r)
print(t.r)

It's not obvious that the let keyword here is only transferred to the backing property and not to the wrapped property.

6 Likes

That is an interesting example. :thinking: Would it be so critical if let in context of a property wrapper should signal that it's only applied to the PW storage? I mean, then we have to expect the result to be whatever the PW's getter returns, which makes your example still valid and legal. It's a consequence of the fact that wrappedValue itself can be computed which allows us to inject code like in your example.

That would basically become a new rule and permit let on wrapped properties.

At least in the context of what I have pitched above as an alternative to the main pitch, which I still fail to fully grasp.

The code @amritpan shared shows synthesized version produced by the type-checker where let keyword doesn’t play the same role as in surface language.

Thank you for shining a bit of light into it, but this still does not make it more understandable to the reader. From the readers perspective this code is illegal for several reasons I previously already have mentioned.

If the type checker still needs the property wrapper annotation, even for the current PWs, fine but this does not belong into the proposal examples as it‘s a source for confusion. Regarding the let keyword. How is the reader without any knowledge about how the type checker works should guess that‘s a ‘different let‘?

You asked how exactly does the transformation look like and that code snippet does exactly that :) That said, my understanding is that @amritpan is going to update the pitch to show equivalent representation of the transformation is the surface language.

Heh, that‘s a bit picky on my wording. :smirk: I am basically only interested on what the legal swift syntactic transformation looks like, not what other toolings see this like, because I as a reader am not that tool and I don‘t necessarily understand how they work. Long story short, I can only provide better feedback if I understand the pitch. :wink:

It depends on what you mean by “the same” in this context let properties that refer to reference types for example might not fit that.

No worries, I am just trying to point out the source of misunderstanding :slight_smile:

1 Like

My mistake! I did not catch the alternative let in the code sample. Let me try again.

The Swift translation for

@SmallNumber let height: Int = 1

should be

private let _height: SmallNumber = SmallNumber(wrappedValue: 1)
var height: Int {
  get { return _height.wrappedValue }
}

If we used an instance init to set a value to height instead

@SmallNumber let height: Int

then the translation will be

private let _height: SmallNumber
var height: Int {
  get { return _height.wrappedValue }
}
init() {
  _height = SmallNumber(wrappedValue: 1)
}

For SwiftUI's @State property wrapper, the let translation does not have a nonmutating set. So,

@State let weekday = "Monday"

will translate to the following and should not be able to be reassigned:

private let _weekday: State<String> = State<String>(wrappedValue: "Monday")
var weekday : String {
  get { return _weekday.wrappedValue }
}
var $weekday: Binding<String> {
  get { return _weekday.projectedValue }
}

If we wanted to mutate a wrapped property inside a ContentView body, then @State would need be attached to a var property. The following would result in an error:

struct ContentView: View {
  @State let weekday: String // change 'let' to 'var' to make it mutable
  
  init(text: String) {
    self.weekday = text
  }
  
  var body: some View {
    VStack {
      Text(weekday)
      
      Button("Weekday") {
        weekday = "Tuesday" // error: cannot assign to property: 'weekday' is a 'let' constant
      }
    }
  }
}
3 Likes

Okay it basically looks like to what I said. This starts to make way more sense now. :)
However I do disagree on the nonmutating set part. I strongly feel that it should stay as it signals that there is some reference semantics hidden there somewhere. In your original examples you do have an object as a property wrapper, which you can still reference and potentially mutate its other variables. It's the same behavior for nonmutating set, there is a reference inside the PW that is used to delagate the set operation through some kind of an object.

This possibility of the mutating in case of State is still unavoidable regardless of let, because the user can still write _weekday.wrappedValue = "Sunday" and perform a non-mutating set operation.

Furthermore I think the error message from your example should be different:

// error: cannot assign to property: 'text' is a 'let' constant

We should signal that text is a computed variable without a mutating get (aka immutable). The wrapped property isn't itself a constant and as I mentioned above is not even guaranteed to be one in certain cases like if nonmutating set is involved or if the wrappedValue's getter on the PW returns random values as it was mentioned upthread.


P.S. tiny nitpick for all SwiftUI readers. The hypothetical example in ContentView above is not recommended, as you shouldn't assign a value to State from the init unless you fully understand the outcome of this operation.