Search code examples
pythondictionarypython-typingmypy

Python is using dict.update not type safe if passing in a TypedDict?


From reading through mypy issues, it seems as if calling dict.update(), and supplying a TypedDict is not type safe. This does not make sense to me.

My question is (particularly from the 2nd issue, linked below):

  • Since a TypedDict is a dict at runtime, why does mypy complain about not being able to pass a TypedDict into dict.update, saying it expects a Mapping?
    • In my mind, dict is like saying Dict[Any, Any], so why not add a TypedDict as a dict value?
    • In your answer,can you please provide a concrete example?
  • In general, why is calling dict.update(SomeTypedDict) not type safe?

There are two examples of this, found in mypy issues:

  1. python/mypy #6462: TypedDict update() does not accept TypedDict with compatible subset keys

This is a pretty subtle issue. The update call is arguably not type safe.

  1. python/mypy #9086: False positive [arg-type] error when updating a dict with a TypedDict

Since TypedDict objects use structural subtyping, there could be additional items not visible through the type Foo, with arbitrary value types. Thus Mapping[str, object] is correct (though it is unintuitive).


Sample Code from python/mypy #9086

from typing import TypedDict

class Foo(TypedDict):
    baz: int

foo: Foo = {"baz": 9000}

# spam is a regular dict, yet mypy errors out when trying to add a TypedDict
# to it.  This doesn't make sense to me, when a regular dict should be like
# saying equal Dict[Any, Any]
spam = {"ham": {"eggs": 5}}
spam["ham"].update(foo)  # error: Argument 1 to "update" of "dict" has
# incompatible type "Foo"; expected "Mapping[str, int]"  [arg-type]

Solution

  • PEP 589 says that:

    First, any TypedDict type is consistent with Mapping[str, object]. Second, a TypedDict type A is consistent with TypedDict B if A is structurally compatible with B.

    A TypedDict with all int values is not consistent with Mapping[str, int], since there may be additional non-int values not visible through the type, due to structural subtyping. These can be accessed using the values() and items() methods in Mapping, for example.

    Example:

    class A(TypedDict):
        x: int
    
    class B(TypedDict):
        x: int
        y: str
    
    def sum_values(m: Mapping[str, int]) -> int:
        n = 0
        for v in m.values():
            n += v  # Runtime error
        return n
    
    def f(a: A) -> None:
        sum_values(a)  # Error: 'A' incompatible with Mapping[str, int]
    
    b: B = {'x': 0, 'y': 'foo'}
    f(b)
    

    Update: Let us consider your sample

    from typing import TypedDict
    
    class Foo(TypedDict):
        baz: int
    
    foo: Foo = {"baz": 9000}
    
    # spam here is not equal Dict[Any, Any]. mypy will infer type for it 
    # as Dict[str, Dict[str, int]]. Consequently, update() method for its 
    # item below will expect Mapping[str, int]  
    spam = {"ham": {"eggs": 5}}
    # If you try to do empty dict as below which indeed has type Dict[Any, Any]
    # spam = {}  # mypy will complain on it "Need type annotation for 'spam'"
    spam["ham"].update(foo)  # error: Argument 1 to "update" of "dict" has 
    # incompatible type "Foo"; expected "Mapping[str, int]"  [arg-type]