Access to one of PEP 589TypedDict’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 647TypeGuard (new in Python 3.10), and the more intuitive and more recent PEP 742TypeIs (3.13). It allows:
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
Would is_typeddict(tp: object) -> TypeIs[_TypedDictProtocol] indeed (as I suggest) be the preferable signature (over the current -> bool signature)?
Is making that change still feasible while preserving backward compatibility? Note
at runtime, TypeIs resolves to simple boolFalse or True;
at typecheck time, I’d only expect beneficial effects (i.e. more precision).
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
Should _TypedDictProtocol be private to typing or not (TypedDictProtocol)?
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.
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] (notTypeIs).