Repository: python/typing_extensions

Issue:
TypedDict removal causing breaking changes
On `4.14.0`, seeing this issue:

Python `3.12.10`:
```
# python -m pip install typing_extensions==4.14.0
Collecting typing_extensions==4.14.0
  Downloading typing_extensions-4.14.0-py3-none-any.whl.metadata (3.0 kB)
Downloading typing_extensions-4.14.0-py3-none-any.whl (43 kB)
Installing collected packages: typing_extensions
Successfully installed typing_extensions-4.14.0

# python
Python 3.12.10 (main, May 22 2025, 01:29:12) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import typing
>>> import typing_extensions
>>>
>>> typing.Type[typing_extensions.TypedDict]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/lib/python3.12/typing.py", line 398, in inner
    return func(*args, **kwds)
           ^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/typing.py", line 1481, in __getitem__
    params = tuple(_type_check(p, msg) for p in params)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/typing.py", line 1481, in <genexpr>
    params = tuple(_type_check(p, msg) for p in params)
                   ^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/typing.py", line 202, in _type_check
    raise TypeError(f"Plain {arg} is not valid as type argument")
TypeError: Plain typing_extensions.TypedDict is not valid as type argument

#
# Reverting to 4.13.0 works fine
#

# python -m pip install typing_extensions==4.13.0
Collecting typing_extensions==4.13.0
  Downloading typing_extensions-4.13.0-py3-none-any.whl.metadata (3.0 kB)
Downloading typing_extensions-4.13.0-py3-none-any.whl (45 kB)
Installing collected packages: typing_extensions
  Attempting uninstall: typing_extensions
    Found existing installation: typing_extensions 4.14.0
    Uninstalling typing_extensions-4.14.0:
      Successfully uninstalled typing_extensions-4.14.0
Successfully installed typing_extensions-4.13.0

# python
Python 3.12.10 (main, May 22 2025, 01:29:12) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import typing
>>> import typing_extensions
>>>
>>> typing.Type[typing_extensions.TypedDict]
typing.Type[TypedDict]
>>>
```

Python `3.13.3`:
```
# python -m pip install typing_extensions==4.14.0
Collecting typing_extensions==4.14.0
  Downloading typing_extensions-4.14.0-py3-none-any.whl.metadata (3.0 kB)
Downloading typing_extensions-4.14.0-py3-none-any.whl (43 kB)
Installing collected packages: typing_extensions
Successfully installed typing_extensions-4.14.0

root@e4af1eca0c5e:/# python
Python 3.13.3 (main, May 22 2025, 01:27:26) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import typing
... import typing_extensions
...
... typing.Type[typing_extensions.TypedDict]
...
Traceback (most recent call last):
  File "<python-input-0>", line 4, in <module>
    typing.Type[typing_extensions.TypedDict]
    ~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.13/typing.py", line 432, in inner
    return func(*args, **kwds)
  File "/usr/local/lib/python3.13/typing.py", line 1639, in __getitem__
    params = tuple(_type_check(p, msg) for p in params)
  File "/usr/local/lib/python3.13/typing.py", line 1639, in <genexpr>
    params = tuple(_type_check(p, msg) for p in params)
                   ~~~~~~~~~~~^^^^^^^^
  File "/usr/local/lib/python3.13/typing.py", line 200, in _type_check
    raise TypeError(f"Plain {arg} is not valid as type argument")
TypeError: Plain typing_extensions.TypedDict is not valid as type argument

#
# Reverting to 4.13.0 works fine
#

# python -m pip install typing_extensions==4.13.0
Collecting typing_extensions==4.13.0
  Downloading typing_extensions-4.13.0-py3-none-any.whl.metadata (3.0 kB)
Downloading typing_extensions-4.13.0-py3-none-any.whl (45 kB)
Installing collected packages: typing_extensions
  Attempting uninstall: typing_extensions
    Found existing installation: typing_extensions 4.14.0
    Uninstalling typing_extensions-4.14.0:
      Successfully uninstalled typing_extensions-4.14.0
Successfully installed typing_extensions-4.13.0

root@e4af1eca0c5e:/# python
Python 3.13.3 (main, May 22 2025, 01:27:26) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import typing
... import typing_extensions
...
... typing.Type[typing_extensions.TypedDict]
...
typing.Type[TypedDict]

```

Discussion:
@JelleZijlstra: This is because in the previous release typing_extensions.TypedDict was a function and now it's an instance of a subclass of `typing._SpecialForm`. This was done as part of the PEP 764 implementation, cc @Viicos.

`Type[TypedDict]` isn't legal in the type system but it worked at runtime, so we should make it keep working for compatibility. I'll try to look for a fix and we can put a 4.14.1 release for this change (plus perhaps any other regressions that get reported).@FlurinArner: @JelleZijlstra just to have an idea of the timeline, when should we expect this `4.14.1` release?@JelleZijlstra: Just published https://pypi.org/project/typing-extensions/4.14.1/, sorry for the delay!

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b2e833b..5d949cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
+# Unreleased
+
+- Fix usage of `typing_extensions.TypedDict` nested inside other types
+  (e.g., `typing.Type[typing_extensions.TypedDict]`). This is not allowed by the
+  type system but worked on older versions, so we maintain support.
+
 # Release 4.14.0 (June 2, 2025)
 
 Changes since 4.14.0rc1:
diff --git a/src/typing_extensions.py b/src/typing_extensions.py
index 5d5a5c7..b97acf8 100644
--- a/src/typing_extensions.py
+++ b/src/typing_extensions.py
@@ -221,7 +221,55 @@ else:
 
 ClassVar = typing.ClassVar
 
+# Vendored from cpython typing._SpecialFrom
+# Having a separate class means that instances will not be rejected by
+# typing._type_check.
+class _SpecialForm(typing._Final, _root=True):
+    __slots__ = ('_name', '__doc__', '_getitem')
+
+    def __init__(self, getitem):
+        self._getitem = getitem
+        self._name = getitem.__name__
+        self.__doc__ = getitem.__doc__
+
+    def __getattr__(self, item):
+        if item in {'__name__', '__qualname__'}:
+            return self._name
+
+        raise AttributeError(item)
+
+    def __mro_entries__(self, bases):
+        raise TypeError(f"Cannot subclass {self!r}")
+
+    def __repr__(self):
+        return f'typing_extensions.{self._name}'
+
+    def __reduce__(self):
+        return self._name
+
+    def __call__(self, *args, **kwds):
+        raise TypeError(f"Cannot instantiate {self!r}")
+
+    def __or__(self, other):
+        return typing.Union[self, other]
+
+    def __ror__(self, other):
+        return typing.Union[other, self]
+
+    def __instancecheck__(self, obj):
+        raise TypeError(f"{self} cannot be used with isinstance()")
+
+    def __subclasscheck__(self, cls):
+        raise TypeError(f"{self} cannot be used with issubclass()")
+
+    @typing._tp_cache
+    def __getitem__(self, parameters):
+        return self._getitem(self, parameters)
+
 
+# Note that inheriting from this class means that the object will be
+# rejected by typing._type_check, so do not use it if the special form
+# is arguably valid as a type by itself.
 class _ExtensionsSpecialForm(typing._SpecialForm, _root=True):
     def __repr__(self):
         return 'typing_extensions.' + self._name
@@ -1223,7 +1271,9 @@ else:
         td.__orig_bases__ = (TypedDict,)
         return td
 
-    class _TypedDictSpecialForm(_ExtensionsSpecialForm, _root=True):
+    class _TypedDictSpecialForm(_SpecialForm, _root=True):
+        __slots__ = ('_name', '__doc__', '_getitem')
+
         def __call__(
             self,
             typename,
@@ -2201,48 +2251,6 @@ else:
         return typing._GenericAlias(self, (item,))
 
 
-# Vendored from cpython typing._SpecialFrom
-class _SpecialForm(typing._Final, _root=True):
-    __slots__ = ('_name', '__doc__', '_getitem')
-
-    def __init__(self, getitem):
-        self._getitem = getitem
-        self._name = getitem.__name__
-        self.__doc__ = getitem.__doc__
-
-    def __getattr__(self, item):
-        if item in {'__name__', '__qualname__'}:
-            return self._name
-
-        raise AttributeError(item)
-
-    def __mro_entries__(self, bases):
-        raise TypeError(f"Cannot subclass {self!r}")
-
-    def __repr__(self):
-        return f'typing_extensions.{self._name}'
-
-    def __reduce__(self):
-        return self._name
-
-    def __call__(self, *args, **kwds):
-        raise TypeError(f"Cannot instantiate {self!r}")
-
-    def __or__(self, other):
-        return typing.Union[self, other]
-
-    def __ror__(self, other):
-        return typing.Union[other, self]
-
-    def __instancecheck__(self, obj):
-        raise TypeError(f"{self} cannot be used with isinstance()")
-
-    def __subclasscheck__(self, cls):
-        raise TypeError(f"{self} cannot be used with issubclass()")
-
-    @typing._tp_cache
-    def __getitem__(self, parameters):
-        return self._getitem(self, parameters)
 
 
 if hasattr(typing, "LiteralString"):  # 3.11+


Test patch:
diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py
index 7fb748b..6bc3de5 100644
--- a/src/test_typing_extensions.py
+++ b/src/test_typing_extensions.py
@@ -4202,6 +4202,12 @@ class TypedDictTests(BaseTestCase):
         self.assertEqual(Emp.__annotations__, {'name': str, 'id': int})
         self.assertEqual(Emp.__total__, True)
 
+    def test_allowed_as_type_argument(self):
+        # https://github.com/python/typing_extensions/issues/613
+        obj = typing.Type[typing_extensions.TypedDict]
+        self.assertIs(typing_extensions.get_origin(obj), type)
+        self.assertEqual(typing_extensions.get_args(obj), (typing_extensions.TypedDict,))
+
     @skipIf(sys.version_info < (3, 13), "Change in behavior in 3.13")
     def test_keywords_syntax_raises_on_3_13(self):
         with self.assertRaises(TypeError), self.assertWarns(DeprecationWarning):