Why does compactMap { $0.property } work but compactMap(\.property) doesn't with non-optional properties?

struct Person {
    let name: String
}

let persons = [
    Person(name: "Mark"),
    Person(name: "Emma"),
]

let mapped1 = persons.compactMap { $0.name }
print(mapped1)

//let mapped2 = persons.compactMap(\.name)
//print(mapped2)

Here I get the expected print output ["Mark", "Emma"].

But when I uncomment the commented lines I get the error

error: Playground.playground:13:34: error: key path value type 'String' cannot be converted to contextual type 'String?'

let mapped2 = persons.compactMap(.name)

Why can compactMap deal just fine with the non-optional strings in the case of { $0.name } but the KeyPath version doesn't work?

After all, this here (thanks to @roosterboy) also works:

func getName(_ person: Person) -> String {
    person.name
}

let mapped3 = persons.compactMap(getName)
print(mapped3)
//prints ["Mark", "Emma"]
5 Likes

This looks like it's just a hole in the keypath-to-function conversion. As you note, the KeyPath<T, U> to (T) -> U conversion is valid, as is the (T) -> U to (T) -> U? conversion.

The error we get here is identical to the error we get for an attempted KeyPath<T, U> to KeyPath<T, U?> conversion, so it seems like the target function type is being transformed into its corresponding keypath type to determine if the conversion is valid, which causes us to miss out on the potential function-function conversions that are allowed.

3 Likes

Isn't

compactMap { $0.name }

demonstrative of human error, though? The compiler can't catch it, but a linter should. It seems good to me that the key path version doesn't compile.

6 Likes

I agree that it probably should not compile if there are no optionals that require the use of compactMap as opposed to just map, I'm just generally a little confused about non-equivalence of these kinds of closures and the corresponding key paths. An adjacent case of unexpected non-equivalence:

let words = ["Hello", "world"]

let mapped1 = words.map { $0 }
print(mapped1) // works as expected

//⚠️ Doesn't work:
//let mapped2 = words.map(\.self)
//print(mapped2)
1 Like

Actually, I think that should not work either: When there is no Optional involved, map is the right function to use; that it compiles is a side effect of the "T can be used like T?" convenience.

1 Like

That's what I'm wondering about. I feel like either both or neither should work.

2 Likes

I disagree. Implicit conversion of T to T? is a very useful feature, in my opinion. This code compiles directly because of this implicit conversion, not as a side-effect thereof. If this is a side-effect, what is the intended purpose of this feature?

\.self as func doesn't work is a compiler bug: [SR-12897] "\.self" not working with keypath-as-function - Examples in Key Path Expressions as Functions proposal don't compile · Issue #55343 · apple/swift · GitHub

workaround:

extension String {
    var this: Self { self }
}

let mapped2 = words.map(\.this)
print(mapped2)
3 Likes

Is the compactMap(\.name) issue above also a bug? Should it be considered a bug? If so, is it already known, or should I report it? (I'm new to this if you can't tell. :sweat_smile:)

No, it's not a bug. Swift does not support the implicit conversion of KeyPath<T, U> to KeyPath<T, U?>.

3 Likes

Right, I suppose my question is: Is this lack of support of the implicit conversion from KeyPath<T, U> to KeyPath<T, U?> an accidental omission that could/should be rectified in the future, or is it a conscious design-decision (and, in the latter case, what are the reasons)?

It's not immediately intuitive to me why this isn't supported when I read:

Swift has an implicit conversion from A to Optional<A> everywhere.

2 Likes

More specifically, that should read something like:

Swift has an implicit conversion from [values of type] A to [values of type] Optional<A> everywhere.

Other conversions of Optional in generic argument position (such as Array<T> to Array<T?>) are special-cased and can't (currently) be applied to arbitrary types in Swift.

IMO, even if we don't allow for the fully general KeyPath<T, U> to KeyPath<T, U?> conversion, we should allow for it in cases where the keypath is already being converted to a function. The KeyPath<T, U> to KeyPath<T, U?> to (T) -> U? chain may not work, but the KeyPath<T, U> to (T) -> U to (T) -> U? path is perfectly valid under the existing conversion rules, so I don't see a great reason why we shouldn't support it.

8 Likes

I am afraid you are confusing values with methods signatures, otherwise you couldn’t write these two distinct methods in a data structure:

func doSomething<T, U>(with t: T) -> U
func doSomething<T, U>(with t: T) -> U?

I'm not sure I'm seeing how this contradicts what @Jumhyn said before.
Elsewhere we can also pass a function (T) -> U where a function (T) -> U? is expected:

struct MyStruct<T, U> {
    let doSomething: (T) -> U?
}

func doSomething(_ text: String) -> Int { 42 }

let myThing = MyStruct(doSomething: doSomething)
1 Like

A key path resolves to a method signature, the compactMap function takes a closure of type (Element) -> T?, if you pass it a key path on the Element type (in your case a property getter) which returns T then it would be like trying to pass a closure specifically typed (Element) -> T and not (Element) -> T?

These are two different types:

typealias JustDoIt = (String) -> Int
typealias: MaybeDoIt = (String) -> Int?

Of course you can write:

let forced: MaybeDoIt = {  Int($0)! }

and thus never return a nil value in its implementation, but still the type of the closure would be (String) -> Int?.

On the other hand, obviously you are not allowed to write this:

let notAllowed: JustDoIt = { Int($0) }

In my example above, MyStruct takes a method (T) -> U? and is perfectly fine with me passing it the function doSomething which is a (T) -> U (where T is String and U is Int) function and does not return an optional.

So shouldn't compactMap, which takes a closure of type (Element) -> T? then also take something that's equivalent to (Element) -> T?

Isn't this a similar situation to your let forced: MaybeDoIt = { Int($0)! } example?

I saw your example, and to me the complier should not let that happen, perhaps it is possible cause the function gets resolved to an auto-closure and inlined by the compiler just as { 42 }

In any event I think either both persons.compactMap { $0.name } and persons.compactMap(\.name) should be valid or neither should be if name is non-optional.

And since I suspect many other things depend on that implicit conversion from type containing U to type containing U? the solution is probably to make both valid rather than the opposite.

I don't think it then would be possible to write methods with the same name but different signature for just the returned type optionality.

More in general for functional programming it is clearer too when two key paths are treated as different types based on their return type.

No, it's allowed because (T) -> U is a subtype of (T) -> U?. This is entirely deliberate.

2 Likes