Swift Combine compactMap does not work (Xcode 13)

Viewed 363

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

1 Answers

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).

Related