Repository: python/typing_extensions

Issue:
Add support for PEP 661 sentinels
We should add a PEP 661-style Sentinel class; see https://discuss.python.org/t/pep-661-sentinel-values/9126/243

cc @Viicos @taleinat.

(For clarity: I won't work on this myself but I'll review a PR.)

Hints from issue comments:
@Viicos: @taleinat I can look at it this week (or when it will be decided whether the module registry is kept) and port your draft implementation linked in the PEP there.@taleinat: The PEP has been [deferred to Python 3.15](https://discuss.python.org/t/pep-661-sentinel-values/9126/243), so I don't think that should be added yet.@Viicos: Yes, but as with typing PEPs, we don't necessarily have to wait for 3.15 to be released (assuming the PEP would be accepted) to have it included in typing-extensions.

To give more context, having it added here early (even if the PEP is still in draft) can allow users/libraries (such as in Pydantic) to experiment with it, and potentially catch issues or provide valuable feedback. Of course, this won't be added in Pydantic as a stable feature until the PEP gets accepted.@AlexWaygood: Yes, we usually add experimental versions of not-yet-accepted typing features well in advance of their PEPs being accepted@taleinat: I wasn't aware of that, cool! In that case, feel free to go ahead.

I'll mention that it seems likely that the internal registry of sentinels will be removed, to be replaced by attribute lookup on the relevant module (perhaps with a mechanism to avoid threading race conditions). In this case, sentinels may be required to be set as module-level attributes.

And in any case, the name of a sentinel will be required to exactly match the name of the variable it is assigned to; a mismatch should be considered an error. (This is similar to namedtuple, I believe, though the requirement in this case should be considered even more strict, since it could affect the singleton identity of the sentinel object.)@HexDecimal: Can this be reopened? I think there are too many issues with the current implementation https://github.com/python/typing_extensions/pull/594#issuecomment-2931510680

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ba1a6d7..92a19a3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,8 @@ New features:
   Patch by [Victorien Plot](https://github.com/Viicos).
 - Add `typing_extensions.Reader` and `typing_extensions.Writer`. Patch by
   Sebastian Rittau.
+- Add support for sentinels ([PEP 661](https://peps.python.org/pep-0661/)). Patch by
+  [Victorien Plot](https://github.com/Viicos).
 
 # Release 4.13.2 (April 10, 2025)
 
diff --git a/doc/index.rst b/doc/index.rst
index 68402fa..21d6fa6 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -1027,6 +1027,34 @@ Capsule objects
    .. versionadded:: 4.12.0
 
 
+Sentinel objects
+~~~~~~~~~~~~~~~~
+
+.. class:: Sentinel(name, repr=None)
+
+   A type used to define sentinel values. The *name* argument should be the
+   name of the variable to which the return value shall be assigned.
+
+   If *repr* is provided, it will be used for the :meth:`~object.__repr__`
+   of the sentinel object. If not provided, ``"<name>"`` will be used.
+
+   Example::
+
+      >>> from typing_extensions import Sentinel, assert_type
+      >>> MISSING = Sentinel('MISSING')
+      >>> def func(arg: int | MISSING = MISSING) -> None:
+      ...     if arg is MISSING:
+      ...         assert_type(arg, MISSING)
+      ...     else:
+      ...         assert_type(arg, int)
+      ...
+      >>> func(MISSING)
+
+   .. versionadded:: 4.14.0
+
+      See :pep:`661`
+
+
 Pure aliases
 ~~~~~~~~~~~~
 
diff --git a/src/typing_extensions.py b/src/typing_extensions.py
index 1ab6220..d4e92a4 100644
--- a/src/typing_extensions.py
+++ b/src/typing_extensions.py
@@ -89,6 +89,7 @@ __all__ = [
     'overload',
     'override',
     'Protocol',
+    'Sentinel',
     'reveal_type',
     'runtime',
     'runtime_checkable',
@@ -4210,6 +4211,42 @@ else:
         )
 
 
+class Sentinel:
+    """Create a unique sentinel object.
+
+    *name* should be the name of the variable to which the return value shall be assigned.
+
+    *repr*, if supplied, will be used for the repr of the sentinel object.
+    If not provided, "<name>" will be used.
+    """
+
+    def __init__(
+        self,
+        name: str,
+        repr: typing.Optional[str] = None,
+    ):
+        self._name = name
+        self._repr = repr if repr is not None else f'<{name}>'
+
+    def __repr__(self):
+        return self._repr
+
+    if sys.version_info < (3, 11):
+        # The presence of this method convinces typing._type_check
+        # that Sentinels are types.
+        def __call__(self, *args, **kwargs):
+            raise TypeError(f"{type(self).__name__!r} object is not callable")
+
+    def __or__(self, other):
+        return typing.Union[self, other]
+
+    def __ror__(self, other):
+        return typing.Union[other, self]
+
+    def __getstate__(self):
+        raise TypeError(f"Cannot pickle {type(self).__name__!r} object")
+
+
 # Aliases for items that are in typing in all supported versions.
 # We use hasattr() checks so this library will continue to import on
 # future versions of Python that may remove these names.


Test patch:
diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py
index a7953dc..c23e94b 100644
--- a/src/test_typing_extensions.py
+++ b/src/test_typing_extensions.py
@@ -65,6 +65,7 @@ from typing_extensions import (
     ReadOnly,
     Required,
     Self,
+    Sentinel,
     Set,
     Tuple,
     Type,
@@ -9096,5 +9097,44 @@ class TestEvaluateForwardRefs(BaseTestCase):
             self.assertIs(evaluate_forward_ref(typing.ForwardRef("ClassVar", is_argument=False), globals=vars(typing)), ClassVar)
 
 
+class TestSentinels(BaseTestCase):
+    def test_sentinel_no_repr(self):
+        sentinel_no_repr = Sentinel('sentinel_no_repr')
+
+        self.assertEqual(sentinel_no_repr._name, 'sentinel_no_repr')
+        self.assertEqual(repr(sentinel_no_repr), '<sentinel_no_repr>')
+
+    def test_sentinel_explicit_repr(self):
+        sentinel_explicit_repr = Sentinel('sentinel_explicit_repr', repr='explicit_repr')
+
+        self.assertEqual(repr(sentinel_explicit_repr), 'explicit_repr')
+
+    @skipIf(sys.version_info < (3, 10), reason='New unions not available in 3.9')
+    def test_sentinel_type_expression_union(self):
+        sentinel = Sentinel('sentinel')
+
+        def func1(a: int | sentinel = sentinel): pass
+        def func2(a: sentinel | int = sentinel): pass
+
+        self.assertEqual(func1.__annotations__['a'], Union[int, sentinel])
+        self.assertEqual(func2.__annotations__['a'], Union[sentinel, int])
+
+    def test_sentinel_not_callable(self):
+        sentinel = Sentinel('sentinel')
+        with self.assertRaisesRegex(
+            TypeError,
+            "'Sentinel' object is not callable"
+        ):
+            sentinel()
+
+    def test_sentinel_not_picklable(self):
+        sentinel = Sentinel('sentinel')
+        with self.assertRaisesRegex(
+            TypeError,
+            "Cannot pickle 'Sentinel' object"
+        ):
+            pickle.dumps(sentinel)
+
+
 if __name__ == '__main__':
     main()