KeyPath Value is Nil, but If-Let succeeds?

I've encountered an unexpected outcome with KeyPath and I'm trying to understand how I'm getting to it. Consider this playground:

Expected:
The if-let should fail because foo.child is nil.

Actual:
The if-let succeeds, but value is "nil" (nil-ish?) In a debugger instead of the Playground it appears as: (Foo.Foo?) nil

It's AnyKeyPath?

If we drop AnyKeyPath in favor of an exactly-typed array things now work as expected:

But that's impractical. The whole point of AnyKeyPath is to enable collections of KeyPaths with different specializations, no?

A guess?

value is actually an Optional<Foo>, which satisfies Any, but is transparently unwrapped to nil by the debugger and the console in the Playground, so it appears as nil, which is entirely misleading.

Of course, trying if value == nil inside the if-let body fails (and the compiler warns it's always going to fail). So the if-let didn't unwrap the optional valueType from the AnyKeyPath?

Can someone confirm that's what's happening here? I can work around it, I just want to make sure I have an explanation.

You're correct. Quoting SE-0161: Smart KeyPaths, which is the proposal that introduced key paths to Swift:

To get or set values for a given root and key path we effectively add the following subscripts to all Swift types.

extension Any {
    subscript(keyPath path: AnyKeyPath) -> Any? { get }
    subscript<Root: Self>(keyPath path: PartialKeyPath<Root>) -> Any { get }
    subscript<Root: Self, Value>(keyPath path: KeyPath<Root, Value>) -> Value { get }
    subscript<Root: Self, Value>(keyPath path: WritableKeyPath<Root, Value>) -> Value { set, get }
}

As you saw, the subscript that takes an AnyKeyPath returns an Any?, and your if let unwraps the outer layer of optionality, leaving you with an Any (which in your case contains an Optional<Foo>, but the compiler can’t know this).

Yes. This is not so different from any other optional value. E.g. if you have a value var s: String? = "Hello", Xcode will happily display this as "hello" in a playground, rather than Optional("hello") or .some("hello"). But I agree that it would be helpful if the debugger made it clear you're dealing with an Optional, especially when it's boxed inside Any.

Correct. If you know you're always dealing with a Foo? inside the Any, you could write the unwrapping as:

if let value = object[keyPath: kp] {
    if case .some(nil) = value as? Foo? {
        print("it's nil")
    }
}

(Or even shorter, if case nil? = … instead of if case .some(nil) = ….)

For arbitrary Optionals wrapped inside Any, you could help yourself with a helper protocol to find out you're dealing with an Optional:

protocol OptionalProtocol {
  var isNil: Bool { get }
}

extension Optional: OptionalProtocol {
  var isNil: Bool { self == nil }
}

func isNil(_ a: Any) -> Bool {
  return (a as? OptionalProtocol)?.isNil ?? false
}

// Usage:
if let value = object[keyPath: kp] {
    if isNil(value) {
        print("it's nil")
    }
}

PS: Please post your sample code as text, not as images. Typing it in by hand is no fun.

5 Likes

What you're looking for is

if case let value?? as Any?? = object[keyPath: kp] {

No. There was once a point in AnyKeyPath, but a decade has passed.

1 Like

I agree that it would be helpful if the debugger made it clear you're dealing with an Optional, especially when it's boxed inside Any .

Entirely. Seeing the subscript definitions from the original pitch makes it clear what’s happening and why. Without those, it’s surprising that one KeyPath ends up unwrapping to nil and one does not.

That is not necessary. It can't be done in a single line like with unwrapping, but it does not require quite so much ceremony.

if
  let value = object[keyPath: kp],
  case nil as Any? = value {

func isNil(_ value: some Any) -> Bool {
  if case nil as Any? = value { true } else { false }
}

func isNil(_ value: (some Any)?) -> Bool {
  if case _?? as Any?? = value { false } else { true }
}
let value: any Any = 1
#expect(!isNil(value))
#expect(!isNil(value as Optional))
#expect(isNil(Int?.none as any Any))
2 Likes

Thanks for the simpler solution.

1 Like