Suggestion to change `typing.is_typeddict`'s return type to `TypeIs[_TypedDictProtocol]`

Issue

Access to one of PEP 589 TypedDict’s dunder attributes (__total__, __*_keys__, and -from 3.15- __closed__ and __extra_items__) currently yields type errors, even after establishing TypedDict-ness explicitly:

def test1(obj: object, /) -> None:
    if is_typeddict(obj):
        print(obj.__required_keys__) 
# MyPy error: "object" has no attribute "__required_keys__"  [attr-defined]

Alternate solutions

This was noted before by @Daraan, who proposed making TypedDict more type-like. That suggestion didn’t find traction, perhaps because the idea deviates from choices explicitly made in PEP 589.
One other way to improve on this is to ‘teach’ each typechecker the implications of is_typeddict() is True as a special case.

Proposal

However, avoiding special casing and using existing machinery seems preferable. That fits the intended purpose of PEP 647 TypeGuard (new in Python 3.10), and the more intuitive and more recent PEP 742 TypeIs (3.13). It allows:

def my_is_typeddict(cls: object, /) -> TypeIs[_TypedDictProtocol]:
    return is_typeddict(cls)

def test2(obj: object, /) -> None:
    if my_is_typeddict(obj):
        print(obj.__required_keys__)
# MyPy is okay

Currently, is_typeddict(tp: object) -> bool, which was added in Python 3.10, returns bool, likely because TypeGuard and TypeIs weren’t available before it.

Questions

  1. Would is_typeddict(tp: object) -> TypeIs[_TypedDictProtocol] indeed (as I suggest) be the preferable signature (over the current -> bool signature)?
  2. Is making that change still feasible while preserving backward compatibility? Note
    • at runtime, TypeIs resolves to simple bool False or True;
    • at typecheck time, I’d only expect beneficial effects (i.e. more precision).
  3. Would something similar to _TypedDictProtocol indeed be the asserted type of a positive is_typeddict outcome? That protocol could look like below to support TypedDict’s incremental functionality:
class _TypedDictProtocol(Protocol):
    """Checkable protocol (evolving with higher Python versions) for `TypeDict` typeforms."""
    __total__: bool
    if sys.version_info >= (3, 9):
        __required_keys__: frozenset[str]
        __optional_keys__: frozenset[str]
    if sys.version_info >= (3, 13):
        __readonly_keys__: frozenset[str]
        __mutable_keys__: frozenset[str]
    if sys.version_info >= (3, 15):
        __closed__: bool
        __extra_items__: type
  1. Should _TypedDictProtocol be private to typing or not (TypedDictProtocol)?
3 Likes

I don’t have any particularly strong objections against defining a Protocol like that, I’d just like to point out that this is one of those cases where TypeIs is actually incorrect and not type safe. TypeGuard is a better fit for this function, since it does not actually verify your Protocol, it’s looking specifically for _TypedDictMeta, so you can’t guarantee, that just because that function returned False, that the object doesn’t still satisfy the Protocol you defined, so the type negation in the False branch would be incorrect.

2 Likes

There is already a _TypedDict class (type-check-only, doesn’t exist at runtime) in the typing typeshed

@type_check_only
class _TypedDict(Mapping[str, object], metaclass=ABCMeta):
    __total__: ClassVar[bool]
    __required_keys__: ClassVar[frozenset[str]]
    __optional_keys__: ClassVar[frozenset[str]]
    # __orig_bases__ sometimes exists on <3.12, but not consistently,
    # so we only add it to the stub on 3.12+
    if sys.version_info >= (3, 12):
        __orig_bases__: ClassVar[tuple[Any, ...]]
    if sys.version_info >= (3, 13):
        __readonly_keys__: ClassVar[frozenset[str]]
        __mutable_keys__: ClassVar[frozenset[str]]

    def copy(self) -> typing_extensions.Self: ...

    ... # more methods here

(__closed__ and __extra_items__ still need to be added)

Apparently it’s used internally by mypy. But making is_typeddict(tp: object) -> TypeGuard[_TypedDict] might work well without having to add anything new.

That said, given that it’s a concrete class, I’m not sure what other implications that would have. Alternatively, _TypedDict could be turned into a type-check-only protocol (_TypedDictProtocol) and then a simple _TypedDict class which inherits _TypedDictProtocol could replace it.

Very true @Daverball.
While the combination of attributes in the protocol is unlikely to occur unintentionally in anything else than a TypedDict (especially in more recent Pythons), it cannot be ruled out. Then is_typeddict() is False would lead to the wrong conclusion.
The most obvious example of such wrong conclusion is when each of _TypedDictProtocol attributes would get a value (= frozenset(), and hence exist at runtime). Then isinstance(_TypedDictProtocol, _TypedDictProtocol) would be True, while is_typeddict(_TypedDictProtocol) is False.

So yes, it should be def is_typeddict(tp: object) -> TypeGuard[_TypedDictProtocol] (not TypeIs).

I wasn’t aware @dr_carlos, and (as per Occam’s razor) I agree it’s preferable to minimise changes.

The source warns as per PR 13816:

# Obsolete, use _typeshed._type_checker_internals.TypedDictFallback instead.

If we change is_typeddict() to return TypeGuard[_TypedDict], could that be un-obsoleted, @srittau?


BTW, _TypedDict is defined in typing.py at runtime, but differently than in typing.pyi, i.e. as empty base class for TypedDict (in its mro):

_TypedDict = type.__new__(_TypedDictMeta, 'TypedDict', (), {})
TypedDict.__mro_entries__ = lambda bases: (_TypedDict,)

That could be somewhat confusing, but I cannot fathom the implications.


BTW 2, the typing_extensions version was already updated for Python 3.15:

1 Like