Key paths and unavailable setters

Currently, a key path formed to a property that has an unavailable setter are WritableKeyPaths. This seems like a bug, since it allows code like dynamic member lookup to quietly compile what should crash at runtime. Toy example:

struct MyModel {
  var text: String {
    get { "" }
    @available(
      *, unavailable, message: "This is unavailable unless..."
    )
    set { fatalError() }
  }
}
struct MyView: View {
  @Binding var model: MyModel
  var body: some View {
    TextField("", text: $model.text)  ✅
  }
}

While tracking the availability itself in the key path seems like a good idea and would allow library users to surface their own error messaging, I imagine that is a complex project to take on. Maybe as a baby step, key paths should at the very least be simply non-writable KeyPaths when the setter is unavailable. Is this worth tracking in a bug or does this work as expected for a reason I'm not considering?

5 Likes

From the programmer point of view it seems like a bug. The question is can it be fixed like a bug or should these changes be added through evolution process. I assume a proposal is needed because of source and ABI breakage.

Yes, please file an issue for this, I'll take a look. This bug looks extremely similar to the one I just fixed in Sema: Diagnose availability of storage accessors in key paths and writebacks by tshortli · Pull Request #72410 · apple/swift · GitHub but I just tested your example and it seems like we'll need another fix to cover it.

There isn't any ABI impact from starting to diagnose this code AFAICT. I think it's just a bug and we should fix it. Not all source breaking changes need to go through evolution review; if there's a clearly missing diagnostic for an existing compiler feature and the amount of real world code that is effected is small enough then a new error can be emitted. Many bug fixes like this that would break a small amount of existing code go into every release of the compiler. If too much code gets broken, the most likely course of action would be to downgrade the diagnostic to a warning until the next language version but this is so clearly wrong that I doubt that would be the right course of action.

2 Likes

What is the expected behavior for partial availability ? Does the key-path type change depending on context like inside a if available ?

+1 for fixing though.

1 Like

Same example without SwiftUI shenanigans:

struct Model {
    var text: String {
        get { "" }
        @available(*, unavailable, message: "This is unavailable")
        set { fatalError() }
    }
}

var model = Model()
// model.text = "hello" // 👍 Setter for 'text' is unavailable: This is unavailable
model[keyPath: \.text] = "hello" // 🤔

Playing devil's advocate: just because it's unavailable on the original type, doesn't mean that it's unavailable in the context of dynamic member lookup.

If, for example, you wrote a wrapper like this

@dynamicMemberLookup
class DictionaryStorage<Wrapped> {
    var backing: [AnyKeyPath: Any] = [:]

     subscript<T>(dynamicMember member: WritableKeyPath<Wrapped, T>) -> T? {
         get {
             backing[member] as? T
         }
        set {
            backing[member] = newValue
       }

    }
}

struct S {
    
    var a: Int {
        get {
            a
        }
        @available(*, unavailable, message: "")
        set {
            fatalError()
        }
    }
    
    var _a: Int = 8
    
}

DictionaryStorage<S>().a = 8

This code presumably would not crash even when an unavailable setter was passed.

Idk if the cost of the issue you're describing is worth permitting the above, but to me this doesn't seem like a bug at all, just an unfortunate consequence of the fact that dynamicMemberLookup doesn't always mean "forward to a real instance of the type."

It’s not about dynamicMemberLookup; it’s about WritableKeyPath. But I agree that versioned availability shouldn’t change the inferred type. I’m not even sure I like unconditional platform-based availability changing the inferred type, because it means there’s a platform difference that’s subtler than #if, but it could probably be justified.

1 Like

at first glance, this would work just as well with a regular non-mutating KeyPath. but then you wonder: what if i want to obtain KeyPaths to properties with unavailable getters? such a keypath would be quite useless as a key path. but then again, you might not care about using it as a key path, you might just be using it as a field identity.

1 Like

Right, the problem starts once we let you form a WritableKeyPath to a property with an unavailable setter because that key path value can then be passed to other arbitrary code that may use the key-path to invoke the setter. I don't think we can allow the key path with that type to be formed in the first place, regardless of how it happens to get used afterwards.

1 Like

I forget if we allow versioned availability on setters alone, but if we do then this isn’t nearly so clear-cut: in iOS 20 a property might be read-only and in iOS 21 it could gain a setter. Can I form a WritableKeyPath to that property? I suppose “no” is a consistent answer, but it’s a disappointing one.

Perhaps "no", unless deployment target is set to iOS 21 – in which case "yes". Would that work?

I don't like solutions that will change how my code type-checks—not just between valid and invalid, but between two valid possibilities—when I update my deployment target, or worse, when I move code in or out of #available checks. But it's one possible design, sure.

1 Like

While a KeyPath might be returned statically, could it perhaps be cast to a WritableKeyPath at runtime for certain availability paths?

BTW, what's the benefit of having @unavailable fatalError-ing setter instead of having no setter at all?

1 Like

For guiding the user e.g.

public struct SomeSpecificStorage<Value>: Sendable {
  ...

  public subscript(key: String) -> Value? {
    get { dict[key] }
    @available(*, unavailable, message: "this is a 'get-only' subscript, use mutating func instead if you are sure you want to save an optional value'")
    set(maybeValue) {}
  }
  
  // Optional values are needed to be handled in a special way
  // While there is a subscript for convenience, it is typically 
  // an error if someone tries to add an optional value
  // In rare cases it may be valid, but Optional values should 
  // not be added accidentally. They should be added with clear reasoning
  // For these specific rare cases this mutating func exists
  public mutating func add(key: String, optionalValue: (any ValueType)?, line: UInt = #line) {
    // some code with specific handling for optional values
  }
}

Done! Key path formed to setter with unavailable key path is writable · Issue #72809 · apple/swift · GitHub

Another example is when a conditional conformance does have an available setter, and the unavailable version can provide more helpful messaging pointing folks in that direction (something arguably Swift could provide diagnostics for, but does not currently).

1 Like