Combine assign to Optional property: alternate versions?

In attempting to use Combine's assign operator, I've run into an issue when attempting to assign to optional properties. In some situations I can't without map(Optional.some) first. In other, the Optional type is actually propagated up the subscription chain, somewhat bizarrely.

Heres a type that illustrates both issues:

import Combine

final class OptionalProperty {
    @Published
    var someString: String?
    
    @Published
    var someOtherProperty: String?
    
    private var tokens: Set<AnyCancellable> = []
    
    init(_ string: String) { someString = string }
    
    func propagatesOptionalUpwards() {
        Just("string")
            // Removing the IUO results in a compiler error: Value of optional type 'String?' must be unwrapped to refer to member 'count' of wrapped base type 'String'
            .filter { $0!.count > 0 } 
            .assign(to: \.someString, on: self)
            .store(in: &tokens)
    }
    
    func cannotAssignToOptional() {
        $someOtherProperty
            .compactMap { $0 }
            .filter { $0.count > 0 }
            // Removing the map results in a compiler error: Key path value type 'String?' cannot be converted to contextual type 'String'
            .map(Optional.some)
            .assign(to: \.someString, on: self)
            .store(in: &tokens)
    }
}

I think the first issue is due to some constraint between the publishers, but it flows opposite the direction I expect. And the second seems to be some sort of issue with Optional promotion, but again in the wrong direction where it should be trying to promote a String to String?.

So there are two questions. Whose bug is this? Combine's or Swift's, or both? And more importantly, how I can consistently fix it? I usually see the second case more often, but my attempts to create an alternate version of assign for keypaths to optional properties haven't worked, usually due to additional optionals in the types I don't expect.

1 Like

I believe both are working as expected, if a little unintuitive. This is because:

  • Just and Value are invariant,
  • ReferenceWritableKeyPath and Value are invariant.

In the first case, what you expect requires the compiler to either:

  • Convert
    Optional.Publisher<Just<String>> to
    Optional.Publisher<Just<String?>>, or
  • Convert
    ReferenceWritableKeyPath<..., String?> to
    ReferenceWritableKeyPath<..., String>.

Neither of which is possible. It's more apparent like this:

// Type of expression is ambiguous without more context
Just<String>("string")
  .assign(to: \.someString, on: self)

The second case is also similar.

1 Like

That's not what I get. That case returns the same error as before:

Key path value type 'String?' cannot be converted to contextual type 'String'

I still don't understand why the conversion is being attempted String? -> String and not the other way around, like Optional promotion usually is. I can just as easily sink there and assign the variable directly, so it seems like the key path should work the same way. And if I manually implement the optional promotion using map(Optional.some) it works fine. So what's the difference?

If this is how it's supposed to work, is there a way to create a version of assign that takes non-optional values and assigns them to an optional using a key path?

Well, if I don't use assign at all, it's trivial:

extension Publisher where Failure == Never {
    func assign<Root: AnyObject>(to path: ReferenceWritableKeyPath<Root, Output?>, on instance: Root) -> Cancellable {
        sink { instance[keyPath: path] = $0 }
    }
}
1 Like

The actual subscript works because you're directly converting String to String?, which is different from converting Just<String> to Just<String?>, given that generic and its placeholders are invariant in Swift.

In theory, assign would work as you're only assigning to it (as opposing to reading from it), but you'd at least need to annotate the covariance with something similar to

extension Just {
  func assign<R, SubValue>(_: ...KeyPath<R, Value>, value: SubValue)
    where SubValue: Value { ... }
}

which is not possible right now. With the status quo, you need to provide the exact type. At best you'd have two overloads, one with non-optional, another with optional (though that could impact type-checker performance).

Yes, interesting, I was just reading that thread. I understand we can't generally make generics covariant, but would supporting optional promotion make sense? Also, would it make sense to files bugs about these error messages or the behavior here? I still think filtering the generics up the Combine change is very confusing, even if technically correct. It would seem an error about the invariance, while mentioning manually doing the transform, would be far more clear.

I'm pretty sure that other kinds of subtyping would need the same mechanism. If we get the optional one, we'd get general cases very easily. We can definitely add more exception, like Array, Dictionary in the link. Though IMO at that point we should just get to proper variance system.

Still, the invariance of ReferenceWritableKeyPath and its Value is most likely correct.

  • If you convert RWKP<String> to RWKP<String?>, you lose the writability,
  • If you convert RWKP<String?> to RWKP<String>, you lose the readability.

The only type that maintains full functionality of RWKP<Value> is RWKP<Value> itself. So the variance needs to be added at assign, not KeyPath.

We could try. At least the message is indeed not very helpful. And admittedly, Swift does have less variance expressivity compared to even Java. Though it's not exactly a bug in a sense that it's behaving exactly as the language spec dictates (and I don't think we can get much better on API side).

1 Like

I understand why the String? -> String demotion would have issues, but why would String -> String? promotion be an issue? Especially when the language does that in non-generic contexts automatically. AFAIUI, optional promotion is automatic precisely because the variance is guaranteed to work.

I'll look into writing up some bugs this weekend.

There are things like this:

class Foo {
  var nonOptional = ""
}

let foo = Foo()

// If we allow this
let keyPath: ReferenceWritableKeyPath<Foo, String?> = \.nonOptional
// This could happen
foo[keyPath: keyPath] = nil

It works fine for things like array because of its value semantic. We never write back to the original array, so this kind of thing can't happen. This is not true in general for other types.

IMO type variance is subtle, and even experienced programmers can miss it in their APIs. So I'm in a love-hate relationship with the fact that Swift simply doesn't have it (all invariant except for exceptional cases).

Except that's the opposite of what I'm talking about.

class Foo {
    var optional: String?
}

let foo = Foo()

// Allowing this:
let keyPath: ReferenceWritableKeyPath<Foo, String> = \.optional
// Would let this happen, which is fine:
foo[keyPath: keyPath] = "some non-optional value"

Currently it produces the same error seen before:

Key path value type 'String?' cannot be converted to contextual type 'String'

But it seems safe to me. :man_shrugging:

You're converting String? to String there, so you lose readability.

let original: ReferenceWritableKeyPath<Foo, String?> = \.optional
//                                           |
// This is where conversion happens          V
let keyPath: ReferenceWritableKeyPath<Foo, String> = original
// Which could lead to this
let a: String = foo[keyPath: keyPath] // bad if foo.optional is nil
1 Like

Notice too that it has nothing to do with the keypath storage whatsoever. This is just the interaction between RWKP and the subscript APIs. So, aside from very narrow cases, automatically detecting safe variance this is much trickier than it looks.

So the real issue is the bidirectional nature of the key path? So if the key path could guarantee unidirectional behavior, it would be valid? I don't think that's current expressible in the language but it's an interesting consideration anyway.

1 Like

Pretty much. A covariant type like Array is relatively rare since you do read and write to things. It is more common to have a function and say "it's fine to make them covariant/contravariant here, I won't write to/read from it".

I think things like KeyPath (read only) can be contravariant with Root, and covariant with Value. Though adding it now would just be another hardcoded exception.

2 Likes