The varying strictness of TypedDict

I was writing some code where I was using httpx.get() and its params parameter. I decided to use a TypedDict for the dictionary I was passing as the argument since it was for a REST API, where the potential keys were fully known. I then ran Pyrefly over my code and got an unexpected error about how "object" is not a subtype of "str". I had no object in my TypedDict, so I didn't understand what was going on. I tried Pyright and it also failed. I then tried ty and it passed! What?! I know ty takes a less strict approach to typing to support a more gradual approach, so I figured there was a strict typing thing I was doing wrong. I did some digging and I found out that a new feature of TypedDict solves the issue for me, and so I figured I would share what I learned.

Starting in Python 3.15 and typing-extensions today, there are two dimensions to TypedDict and how keys and their existence are treated. The first dimension is whether the specified keys in a TypedDict are all required or not (controlled by the total argument or Required and NotRequired on a per-key basis). This represents whether every key specified in your TypedDict must be in the dictionary or not. So if you have a TypedDict of:

class OptionalOpen(typing_extensions.TypedDict, total=False):
    spam: str

it means the "spam" key is optional. To make it required you just set total=True or spam: Required[str]:

class RequiredOpen(typing_extensions.TypedDict, total=True):
    spam: str

This concept has been around since Python 3.8 when TypedDict was introduced, with Required and NotRequired added in Python 3.11.

But starting in Python 3.15, a second dimension has been introduced that affects whether the TypedDict is closed. By default, a dictionary that is typed to a TypedDict can have any optional keys that it wants. So with either of our example TypedDict above, you could have any number of extra keys, each with any value. So what is a type checker to do if you reference some key that isn't defined by the TypedDict? Since the arbitrary keys are legal, you assume the "worst", and that the value for the key is object as that's the base class of everything.

So, let's say you have a function that takes a Mapping of str keys and str values:

def func(data: collections.abc.Mapping[str, str]) -> None:
    print(data["spam"])

It turns out that if you try to pass in a dictionary that is typed to either of our TypedDict examples you get a type failure like this (this is from Pyright):

/home/brett/py/typeddict_typing.py
  /home/brett/py/typeddict_typing.py:26:6 - error: Argument of type "OptionalOpen" cannot be assigned to parameter "data" of type "Mapping[str, str]" in function "func"
    "OptionalOpen" is not assignable to "Mapping[str, str]"
      Type parameter "_VT_co@Mapping" is covariant, but "object" is not a subtype of "str"
        "object" is not assignable to "str" (reportArgumentType)

This happens because Mapping[str, str] only accepts values of str, but with our TypedDict there is the possibility of some unspecified key having a value of object. As such, e.g. Pyright complains that you can't use an object where str is expected, since you can't substitute anything that inherits from object for a str (that's what the variance bit is all about in that error message).

So how do you solve this? You say the TypedDict cannot have any keys that are not specified; it's closed via the closed argument introduced in PEP 728 (currently, there are no docs for this in Python 3.15 even though it's implemented):

class OptionalClosed(typing_extensions.TypedDict, total=False, closed=True):
    spam: str
    

With that argument you tell the type checkers that unless a key is specified in the TypedDict, the key isn't allowed to exist. That means our example TypedDict will only ever have keys that have a str value since we only have one possible key and its type is str. As such, that makes it a Mapping[str, str] since the only key it can ever have has a value type of str.

Another way to make this work is with the extra_items parameter that also came from PEP 728. What that parameter lets you do is specify the value type for any keys that are not defined by the TypedDict:

class RequiredOpen(typing_extensions.TypedDict, extra_items=str):
    spam: str

So now any dictionary that is typed to this TypedDict will be presumed to have str be the type for any keys that aren't spam. That then means our TypedDict supports the Mapping[str, str] type as the only defined key is str and we have said any other key will have a value type of str.