4

I get an unexpected result by using Combine's assign(to:) directly after compactMap. Here the code, it's 100% reproducible for me in Xcodes Playground, Xcode Version 13.0 (13A233).

import Combine
import Foundation

class Test {
    @Published var current: String?
    @Published var latest1: String?
    @Published var latest2: String?
    @Published var latest3: String?
    
    init() {
        // Broken? - compactMap passes nil downstream
        self.$current
           .compactMap { $0 }
           .assign(to: &self.$latest1)
        
        // Fixed by specifying the transform closure type explicitely
        self.$current
            .compactMap { value -> String? in value }
            .assign(to: &self.$latest2)
        
         // Fixed by an additional map inbetween
         self.$current
            .compactMap { $0 }
            .map { $0 }
            .assign(to: &self.$latest3)
    }
}

let obj = Test()

obj.current = "success"

print("current: \(String(describing: obj.current))")
print("latest1: \(String(describing: obj.latest1))")
print("latest2: \(String(describing: obj.latest2))")
print("latest3: \(String(describing: obj.latest3))")
print("")

obj.current = nil

print("current: \(String(describing: obj.current))")
print("latest1: \(String(describing: obj.latest1))") // nil shouldn't arrive here
print("latest2: \(String(describing: obj.latest2))")
print("latest3: \(String(describing: obj.latest3))")

// Output:
//current: Optional("success")
//latest1: Optional("success")
//latest2: Optional("success")
//latest3: Optional("success")
//
//current: nil
//latest1: nil
//latest2: Optional("success")
//latest3: Optional("success")

Maybe I miss something obvious here? Or could this be a bug in Combine?. Thanks for your attention.


Update: I updated the example code with a more concise version

Darko
  • 9,655
  • 9
  • 36
  • 48
  • 1
    Probably a duplicate of https://stackoverflow.com/questions/59637100/how-does-swift-referencewritablekeypath-work-with-an-optional-property – matt Oct 01 '21 at 14:22

1 Answers1

3

The problem here is Swift's type inference mechanism, there's no bug in Combine. Let me explain why.

compactMap(transform:) maps a Value? to a T, however in your case T (the type of self.latest) is actually String?, aka a string optional. So the whole pipeline is re-routed to a String? output, which matches what you see on the screen.

When you don't specify the types involved, Swift is eager to satisfy your code requirements, so when it sees assign(to: &self.$latest), even if latest is String?, it automatically reroutes compactMap from (String?) -> String to (String?) -> String?. And it's almost impossible to get a nil value with this kind of transform :)

Basically, the "problematic" pipeline has the following types inferred:

self.$current
    .compactMap { (val: String?) -> String? in return val }
    .assign(to: &self.$latest) 

, this because it's perfectly valid in Swift to compactMap from an optional to another optional.

Try writing @Published var latest: String = "", and you'll the expected behaviour. Note that I said "expected", not "correct", as "correct" depends on how your code looks like.

Also, try splitting in two the compactMap pipeline:

let pub = self.$current.compactMap { $0 }
pub.assign(to: &self.$latest) // error: Inout argument could be set to a value with a type other than 'Published<String?>.Publisher'; use a value declared as type 'Published<String>.Publisher' instead

Basically, by assigning to an optional property, you dictate the behaviour of the whole pipeline, and if everything goes well, and there are no type mismatches, you can end up with unexpected behaviour (not incorrect behaviour, just unexpected).

Cristik
  • 30,989
  • 25
  • 91
  • 127
  • Was just playing around in a Playground and coming back to post this same reply. I didn't know that would work that way but good to know :D – Fogmeister Oct 01 '21 at 13:29
  • 1
    @Fogmeister yeah, type inference can sometimes help you, sometimes confuse you :) Most of the time it works great, and most of the time it doesn't work it's due to the person who wrote the code :) – Cristik Oct 01 '21 at 13:32
  • 1
    A small edit in your answer. `compactMap` signature is actually `(Value?) -> (T)` not `(T?) -> (T)` :D – Fogmeister Oct 01 '21 at 13:38
  • 1
    @Cristik Thanks for you detailed answer but I still don't get it. Even if compactMap infers the T return type as String? -> why does it let nil pass? This would mean that compactMap never works if the next downstream expects an optional and I didn't specify T explicitely. Sounds broken (by concept) to me. – Darko Oct 01 '21 at 14:22
  • That's compactMap's signature: – Darko Oct 01 '21 at 14:27
  • `func compactMap(_ transform: @escaping (Self.Output) -> T?) -> Publishers.CompactMap`. The passed transformation closure is allowed to return an optional T, but the result type is guaranteed to always be an non optional T. – Darko Oct 01 '21 at 14:28
  • But why do you focus on the output T. It's about the input value. The input is just one level Optional. Even if the output is Optional> - who cares? compactMap's job is to filter the Optional on input side. – Darko Oct 01 '21 at 15:10
  • Btw. I just managed to fix it like this: `.compactMap { value -> String? in value }` By specifying the transform closure type explicitly. So it's cant be about the output. – Darko Oct 01 '21 at 15:11
  • @Darko ah, what you've done there is help the compiler by providing type information. Without that `-> String` the compiler has to determine what the type actually should be in that particular place. And type inference is much much much harder than type checking. Type checking being that you tell it what type to expect and it can verify the type. Type inference is where you don't tell it anything about the types and it has to work it out. So yes, what you have done is valid but also if you had put `-> String??` then that would also be valid and nil would produce your initial result. – Fogmeister Oct 01 '21 at 15:36
  • Thanks for the explanations, but it's somehow hard for me to accept this as a valid behavior. I created a ticket: https://bugs.swift.org/browse/SR-15270 – Darko Oct 01 '21 at 15:40
  • @Darko now you edited the question and made my answer kinda invalid. That's not nice... – Cristik Oct 01 '21 at 15:43
  • If anything I would say this is a bug in the type inference of the compiler rather than the `compactMap`. If you update the type of the `compactMap` to `value -> String?? in value` then the unwanted behaviour returns. Which suggests that `compactMap` is doing what it's told. But the type inference system is not determining what the type should be correctly. Possibly... – Fogmeister Oct 01 '21 at 15:57