Dictionary with Value type Optional<Any>

I've read Dictionary with optional values which makes sense, but I'm still having trouble explaining this:

var dict1 = [String: Any?]()
dict1["key"] = Optional<String>.none
print(dict1) // [:]

var dict2 = [String: Any?]()
dict2["key"] = Optional<Any>.none
print(dict2) // ["key": nil]

With dict2, we insert nil. Why doesn't that work with dict1? Certainly Optional<String> can't be mistaken for Optional<Optional<Any>>. Can it?

somehow "If you assign nil to an existing key, the key and its associated value are removed" plays here but in a non obvious way.

here's another one for you to try:

    dict1["key", default: "hehe"] = Optional<String>.none
    print(dict1) // ["key": nil]
    var dict2 = [String: Any?]()
    dict2["key", default: "hehe"] = Optional<Any>.none
    print(dict2) // ["key": nil]

Yes. You start getting warnings about it once you add more than the total amount of optionality on the left side of =.

// no warning
dict1["key"] = String??.none 

// Expression implicitly coerced from 'Optional<String????????????????>' to 'Any??'
dict1["key"] = String?????????????????.none

Any? as a dictionary value type is just the worst, so maybe don't do that. But if you have to, can you explicitly type it?

dict1["key"] = String?.none as Any? // ["key": nil]

That behaves differently because the get/set type is not double-optional, only single.

5 Likes

To elaborate on what others have said, this issue arises because there are two different "ways" to get a value of type Optional<Any> from the value Optional<String>.none. One is to do the covariant optional-optional conversion (since String is a subtype of Any) to turn Optional<String>.none into Optional<Any>.none. The other is to interpret Optional<String>.none as a value of type Any and then perform the value-to-optional conversion to obtain a value of type Optional<Any> (containing .some(Optional<String>.none as Any)).

I'll echo @anon9791410 in saying that Any? is a pretty awful type to work with in general, especially when the underlying concrete type is some sort of optional (as it is with dictionaries). I would avoid it at all costs, or you'll constantly be fighting the language and specifying explicit type coercions to avoid the implicit rules that (usually) make it much more ergonomic to work with optionals.

5 Likes

Thanks all for the replies. I think I’m getting closer to understanding. Let me see if I’m getting this right:

In dict1, the assigned value must be interpreted as Optional<Optional<Any>>.none

In dict2, the assigned value must be interpreted as Optional<Optional<Any>>.some(.none). This is a result of optional promotion since the type specified is Optional<Any>.

In this specific example, how do we get from Optional<String>.none to Optional<Optional<Any>>.none?

Is String a subtype of Optional<Any>? Is optional promotion applied to the generic parameter String to obtain Optional<Optional<String>> which is then converted to Optional<Optional<Any>>? Neither of these seem right to me, so I think there must be another answer that I’m not seeing.

No, you're right to be confused: my explanation as written was... incomplete. I confused myself with the extra layer of optionality. :sweat_smile:

If we look at the dump of the type-checked expression, this is what we get:

(assign_expr type='()' location=test.swift:2:14 range=[test.swift:2:1 - line:2:33]
  (subscript_expr type='@lvalue Any??' location=test.swift:2:6 range=test.swift:2:1 - line:2:12] decl=Swift.(file).Dictionary extension.subscript(_:) [with (substitution_map generic_signature=<Key, Value where Key : Hashable> (substitution Key -> String) (substitution Value -> Any?))]
    (inout_expr implicit type='inout Dictionary<String, Any?>' location=test.swift:2:1 range=[test.swift:2:1 - line:2:1]
      (declref_expr type='@lvalue [String : Any?]' location=test.swift:2:1 range=[test.swift:2:1 - line:2:1] decl=test.(file).dict1@test.swift:1:5 function_ref=unapplied))
    (argument_list
      (argument
        (string_literal_expr type='String' location=test.swift:2:7 range=[test.swift:2:7 - line:2:7] encoding=utf8 value="key" builtin_initializer=Swift.(file).String extension.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:) initializer=**NULL**))
    ))
  (optional_evaluation_expr implicit type='Any??' location=test.swift:2:33 range=[test.swift:2:16 - line:2:33]
    (inject_into_optional implicit type='Any??' location=test.swift:2:33 range=[test.swift:2:16 - line:2:33]
      (inject_into_optional implicit type='Any?' location=test.swift:2:33 range=[test.swift:2:16 - line:2:33]
        (erasure_expr implicit type='Any' location=test.swift:2:33 range=[test.swift:2:16 - line:2:33]
          (bind_optional_expr implicit type='String' location=test.swift:2:33 range=[test.swift:2:16 - line:2:33] depth=0
            (dot_syntax_call_expr type='Optional<String>' location=test.swift:2:33 range=[test.swift:2:16 - line:2:33]
              (declref_expr type='(Optional<String>.Type) -> Optional<String>' location=test.swift:2:33 range=[test.swift:2:33 - line:2:33] decl=Swift.(file).Optional.none [with (substitution_map generic_signature=<Wrapped> (substitution Wrapped -> String))] function_ref=unapplied)
              (argument_list implicit
                (argument
                  (type_expr type='Optional<String>.Type' location=test.swift:2:16 range=test.swift:2:16 - line:2:31] typerepr='Optional<String>'))
              ))))))))

The interesting stuff begins at optional_evaluation_expr (since that is the left-hand side of the assignment). If you just look at the type declarations you can see that the Optional<String> is converted into Any?? via the following process (proceeding from inside to outside):

  1. Convert from Optional<String> to String via bind_optional_expr.
    • This generally corresponds to the presence of an optional-chaining ?, but in this place it's implicit in the optional-optional conversion. A bind_optional_expr always has a corresponding outer optional_evaluation_expr. If the bind_optional_expr fails to find a value, the optional_evaluation_expr immediately evaluates to .none.
  2. Convert String to Any via erasure_expr (i.e., existential conversion).
  3. Convert Any to Any? via inject_into_optional (i.e., wrap it in .some(...)).
  4. Convert Any? to Any?? via inject_into_optional.

So, in the case where we are converting Optional<String>.none to type Optional<Optional<Any>>, we follow the process in the bullet point below (1), i.e., the bind_optional_expr fails and the optional_evaluation_expr immediately evaluates to .none. Since the type of the optional_evaluation_expr is Optional<Optional<Any>>, so we end up with the value Optional<Optional<Any>>.none.

As for why we end up with this type-checked expression, when solving the left-hand side, we have to convert from Optional<String> (the type of Optional<String>.none) to Optional<Optional<Any>> (the type expected by the subscript). Optional-optional conversion is permitted when we can convert the wrapped types, so we try to convert String to Optional<Any>. This conversion is permitted when the non-optional type is convertible to the optional's wrapped type, so we try to convert String to Any, which straightforwardly succeeds (since anything can convert to Any).

4 Likes

How does one obtain this?

3 Likes
swiftc -dump-ast <file>

should do the trick

3 Likes