How does Swift ReferenceWritableKeyPath work with an Optional property?

You (Matt) probably know at least some of this already, but here are some facts for other readers:

  • Swift infers types on one whole statement at a time, but not across statements.

  • Swift allows type inference to automatically promote an object of type T to type Optional<T>, if necessary to make the statement type-check.

  • Swift also allows type inference to automatically promote a closure of type (A) -> B to type (A) -> B?. In other words, this compiles:

      let a: (Data) -> UIImage? = { UIImage(data: $0) }
      let b: (Data) -> UIImage?? = a
    

    This came as a surprise to me. I discovered it while investigating your problem.

Now let’s consider the use of assign:

let p0 = Just(Data())
    .compactMap { UIImage(data: $0) }
    .receive(on: DispatchQueue.main)
    .assign(to: \.image, on: self.iv)

Swift type-checks this entire statement simultaneously. Since \UIImageView.image‘s Value type is UIImage?, and self.iv‘s type is UIImageView!, Swift has to do two “automatic” things to make this statement type-check:

  • It has to promote the closure { UIImage(data: $0) } from type (Data) -> UIImage? to type (Data) -> UIImage?? so that compactMap can strip off one level of Optional and make the Output type be UIImage?.

  • It has to implicitly unwrap iv, because Optional<UIImageView> has no property named image, but UIImageView does.

These two actions let Swift type-check the statement successfully.

Now suppose we break it into three statements:

let p1 = Just(Data())
    .compactMap { UIImage(data: $0) }
    .receive(on: DispatchQueue.main)
let a1 = Subscribers.Assign(object: self.iv, keyPath: \.image)
p1.subscribe(a1)

Swift first type-checks the let p1 statement. It has no need to promote the closure type, so it can deduce an Output type of UIImage.

Then Swift type-checks the let a1 statement. It must implicitly unwrap iv, but there’s no need for any Optional promotion. It deduces the Input type as UIImage? because that is the Value type of the key path.

Finally, Swift tries to type-check the subscribe statement. The Output type of p1 is UIImage, and the Input type of a1 is UIImage?. These are different, so Swift cannot type-check the statement successfully. Swift does not support Optional promotion of generic type parameters like Input and Output. So this doesn’t compile.

We can make this type-check by forcing the Output type of p1 to be UIImage?:

let p1: AnyPublisher<UIImage?, Never> = Just(Data())
    .compactMap { UIImage(data: $0) }
    .receive(on: DispatchQueue.main)
    .eraseToAnyPublisher()
let a1 = Subscribers.Assign(object: self.iv, keyPath: \.image)
p1.subscribe(a1)

Here, we force Swift to promote the closure type. I used eraseToAnyPublisher because otherwise p1‘s type is too ugly to spell out.

Since Subscribers.Assign.init is public, we can also use it directly to make Swift infer all the types:

let p2 = Just(Data())
    .compactMap { UIImage(data: $0) }
    .receive(on: DispatchQueue.main)
    .subscribe(Subscribers.Assign(object: self.iv, keyPath: \.image))

Swift type-checks this successfully. It is essentially the same as the statement that used .assign earlier. Note that it infers type () for p2 because that’s what .subscribe returns here.


Now, back to your keypath-based assignment:

class Thing {
    var iv: UIImageView! = UIImageView()

    func test() {
        let im = UIImage()
        let kp = \UIImageView.image
        self.iv[keyPath: kp] = im
    }
}

This doesn’t compile, with the error value of optional type 'UIImage?' must be unwrapped to a value of type 'UIImage'. I don’t know why Swift can’t compile this. It compiles if we explicitly convert im to UIImage?:

class Thing {
    var iv: UIImageView! = UIImageView()

    func test() {
        let im = UIImage()
        let kp = \UIImageView.image
        self.iv[keyPath: kp] = .some(im)
    }
}

It also compiles if we change the type of iv to UIImageView? and optionalize the assignment:

class Thing {
    var iv: UIImageView? = UIImageView()

    func test() {
        let im = UIImage()
        let kp = \UIImageView.image
        self.iv?[keyPath: kp] = im
    }
}

But it does not compile if we just force-unwrap the implicitly-unwrapped optional:

class Thing {
    var iv: UIImageView! = UIImageView()

    func test() {
        let im = UIImage()
        let kp = \UIImageView.image
        self.iv![keyPath: kp] = im
    }
}

And it does not compile if we just optionalize the assignment:

class Thing {
    var iv: UIImageView! = UIImageView()

    func test() {
        let im = UIImage()
        let kp = \UIImageView.image
        self.iv?[keyPath: kp] = im
    }
}

I think this might be a bug in the compiler.

Leave a Comment