1

v1. No parameters: ✅ works as expected

Normally, I can create a Trimmed wrapper like this:

@propertyWrapper
struct Trimmed {
  private(set) var value: String = ""

  var wrappedValue: String {
    get { value }
    set { value = newValue.trimmingCharacters(in: .whitespacesAndNewlines) }
  }

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

struct Post {
  @Trimmed
  var title: String
  @Trimmed
  var body: String = ""
}

let post = Post(
  title: "  title  ",
  body: "  body  "
)
post.title == "title"
post.body == "body"

Notice how it's working flawlessly for both parameters without default values (e.g. title), and those with default values (e.g. body).


v2. One parameter: ❌ Does not compile

Now imagine I don't want to hardcode .whitespacesAndNewlines, and instead allow the implementor to supply this value:

@propertyWrapper
struct Trimmed2 { // 
  private(set) var value: String = ""
  let characterSet: CharacterSet // 

  var wrappedValue: String {
    get { value }
    set { value = newValue.trimmingCharacters(in: characterSet) } // 
  }

  init(
    wrappedValue: String,
    characterSet: CharacterSet // 
  ) {
    self.characterSet = characterSet
    self.wrappedValue = wrappedValue
  }
}

struct Post2 {
  @Trimmed2(characterSet: .whitespaces) // ❌ Missing argument for parameter 'wrappedValue' in call
  var title: String
  @Trimmed2(characterSet: .whitespaces)
  var body: String = ""
}
 

The first problem I have is that title, the parameter with no default value, does not compile. It requires that I add the wrappedValue value.


v3. Supply default property values: ⚠️ Consumers can omit the parameter

The easiest way to fix this compiler error is by giving it a default value like body does:

struct Post3 {
  @Trimmed2(characterSet: .whitespaces)
  var title: String = ""
  @Trimmed2(characterSet: .whitespaces)
  var body: String = ""
}

let post3 = Post3(
  // ⚠️ Undesirable since `title` can now be left out of the constructor
  body: "  body  "
) // 
post3.title == "" // ⚠️
post3.body == "body"

However, now I lost the ability to force consumers to supply a title value via the auto-synthesized constructor.


v4. Supply default wrappedValue: ⚠️ PropertyWrapper exposed to consumers

If instead of supplying the default value, I comply with the original error message and supply wrappedValue, title is now required again, but it has much bigger issues.

struct Post4 {
  @Trimmed2(wrappedValue: "", characterSet: .whitespaces)
  var title: String
  @Trimmed2(characterSet: .whitespaces)
  var body: String = ""
}

let post4 = Post4(
  title: .init(wrappedValue: "  title  ", characterSet: .decimalDigits), // ⚠️ PropertyWrapper exposed to consumers
  body: "  body  ")

post4.title == "  title  " // ⚠️ Whitespace no longer removed
post4.body == "body"

The bigger issue is that Trimmed is now exposed to consumers, so they can't simply supply a String value, and worse, they can change the behavior of the struct (e.g. by supplying a different characterSet).


v5. Supply custom init: ⚠️ No longer get auto-synthesized init

One way to resolve all these issues is to not rely on the auto-synthesized init, and instead supply our own. To solve the syntax error in v2, this also requires supplying a default value for title. That could either be done via the same way it's done for body (e.g. var title: String = ""), or by adding a default value to Trimmed.wrappedValue. Both are functionally equivalent.

@propertyWrapper
struct Trimmed5 {
  private(set) var value: String = ""
  let characterSet: CharacterSet

  var wrappedValue: String {
    get { value }
    set { value = newValue.trimmingCharacters(in: characterSet) }
  }

  init(
    wrappedValue: String = "", // 
    characterSet: CharacterSet
  ) {
    self.characterSet = characterSet
    self.wrappedValue = wrappedValue
  }
}

struct Post5 {
  @Trimmed5(characterSet: .whitespaces)
  var title: String
  @Trimmed5(characterSet: .whitespaces)
  var body: String = ""

  init(title: String, body: String = "") {
    self.title = title
    self.body = body
  }
}

let post5 = Post5(title: "  title  ", body: "  body  ")
post5.title == "title"
post5.body == "body"

However, I'm wondering if there is a way for parameterized PropertyWrapper + no default arguments + auto synthesized constructors to work nicely together.


If I have a parameterized PropertyWrapper, how do I force a consumer to supply it a value in its auto-synthesized constructor?

(e.g. how do I get v2 to compile without unwanted side effects?)


Note: The original question was about getting default properties values on parameterized values to work as expected, but after doing some research, it seems like the underlying problem is the one mentioned above. Thus the question was simplified.

Senseful
  • 86,719
  • 67
  • 308
  • 465
  • 1
    It appears to be impossible at the moment and there's a proposal to change that: https://forums.swift.org/t/allow-property-wrappers-with-multiple-arguments-to-defer-initialization-when-wrappedvalue-is-not-specified/38319 – New Dev Jul 31 '20 at 17:44

2 Answers2

0

You can try the following:

@propertyWrapper
struct Trimmed {
    private var value: String?
    private let defaultValue: String
    private let characterSet: CharacterSet

    var wrappedValue: String {
        get { value ?? defaultValue }
        set { value = newValue.trimmingCharacters(in: characterSet) }
    }

    init(value: String? = nil, defaultValue: String = "", characterSet: CharacterSet = .whitespaces) {
        if let value = value {
            self.value = value.trimmingCharacters(in: characterSet)
        }
        self.defaultValue = defaultValue
        self.characterSet = characterSet
    }
}

EDIT

Judging by your multiple edits to the question, the problem is not with the property wrapper but with the struct as well.

You want both @Trimmed to be constant in the struct but parametrised otherwise. So you can either restrict changing the variables of the post (with private(set)) or create a custom @Trimmed with only specific fields.

struct Post {
    @Trimmed(characterSet: .whitespaces)
    private(set) var title: String

    @Trimmed(defaultValue: "")
    private(set) body: String
    
    init(title: String, body: String) {
        self.title = .init(title)
        self.body = .init(body)
    }
}

let post = Post(
    title: "text",
    body: "body"
)
pawello2222
  • 46,897
  • 22
  • 145
  • 209
  • This the same problem as v4: (1) consumers must supply a PropertyWrapper; and a similar problem to v5: (2) characterSet must be supplied by implementor. One of the desired behaviors is that the property wrapper has a default value for character set. – Senseful Jul 31 '20 at 16:04
  • @Senseful Please check the updated version for (1) and (2). – pawello2222 Jul 31 '20 at 16:19
  • @Senseful I see you significantly changed your question. However, my answer still remains valid. Please check if it's what you're looking for. – pawello2222 Jul 31 '20 at 16:36
  • You can no longer supply a default value for properties with the updated version: `struct Post7 { @Trimmed var body: String = "" } }` Error: `Extra argument 'wrappedValue' in call` – Senseful Jul 31 '20 at 16:46
  • @Senseful But you can do it with a `defaultValue` parameter of `@Trimmed`. – pawello2222 Jul 31 '20 at 16:48
  • The latest version w/ `Post` seems like it would work mainly due to the explicit constructor. Interesting idea with the `default` parameter to bypass the issue with default arguments + no-default arguments behaving differently. However, I still wonder if there is a way to (1) avoid the explicit constructor, (2) continue relying on Swift's implementation of default values. It seems like if we're okay with an explicit constructor, we wouldn't need the `defaultValue` anyways. Let me update the post to clarify that. – Senseful Jul 31 '20 at 16:56
  • And yes, you're right that it does seem like it's more of an issue on the usage side rather than the PropertyWrapper side. At the end of my original post, I updated it to specify that I think the more fundamental issue is that auto synthesized init + PropertyWrapper with properties don't play nicely together. The post was then flagged as not being specific enough, so I simplified it with this new conclusion in mind to resolve the flag. Apologies that it wasn't updated before your answer. However, there still might be some interesting nuggets with that defaultValue you suggest that could help – Senseful Jul 31 '20 at 17:04
  • @Senseful If you decide to use a custom init then you can supply default parameters to it: `init(title: String = "", body: String = "")`. Then there is no point in doing `@Trimmed var body: String = ""` if you plan to implement a custom init or if you can use the `defaultValue` parameter of the property wrapper. I feel like I answered your question. If you still have doubts or not everything was explained maybe the question indeed needs more focus. – pawello2222 Jul 31 '20 at 17:09
  • Good point about `= ""` in `Post`. I can place it in the `Trimmed.init` method instead to get closer to v2. I updated the post to reflect this. However, I don't see a way to get `defaultValue` to work without that custom init method. If I try your code and remove the custom `init` method, I end up with a `Post` that requires consumers to supply `Trimmed` values. It appears that a custom init method is required for defaultValue to work. And if a custom init method is already supplied, we might as well use the simpler Swift syntax for supplying default values (e.g. v5). – Senseful Jul 31 '20 at 17:40
  • @Senseful Unfortunately as you see in the comments, what you want it's not currently possible. Hope my answer helped as a workaround :) – pawello2222 Jul 31 '20 at 18:39
0

As mentioned, this not currently supported and there is a proposal to change that.

One workaround is to erase any arguments by using composition. This is similar to the @UnitInterval example, where it uses a @Clamping internally.

This is what it might look like for Trimmed:

@propertyWrapper
struct Trimmed {
  private(set) var value: String = ""
  let characterSet: CharacterSet

  var wrappedValue: String {
    get { value }
    set { value = newValue.trimmingCharacters(in: characterSet) }
  }

  init(
    wrappedValue: String,
    characterSet: CharacterSet
  ) {
    self.characterSet = characterSet
    self.wrappedValue = wrappedValue
  }
}

@propertyWrapper
struct TrimmedWhitespace {
  @Trimmed(characterSet: .whitespaces)
  var wrappedValue: String = ""

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

struct Post {
  @TrimmedWhitespace
  var title: String
  @TrimmedWhitespace
  var body: String = ""
}

let post = Post(title: "  title  ", body: "  body  ")
post.title == "title"
post.body == "body"

Notice how this works the same as v1 where an argument with a defalut value and one without both function as expected.

Senseful
  • 86,719
  • 67
  • 308
  • 465