by Cecil Curry (leycec), sole maintainer of beartype, a bald man in a cottage surrounded by a beautiful wife, three hairy cats of uncertain taxonomic status, and a pile of unread PEPs
PEP 827 introduces type manipulation operators to Python's typing module: composable primitives that construct, inspect, and transform type annotations at both static-analysis time (for type-checkers) and runtime (for frameworks like beartype, Pydantic, and FastAPI). This rewrite discards all content irrelevant to runtime type-checker implementors and rebuilds the conceptual hierarchy from the bottom up, because the original PEP assumes the reader already thinks in type theory. I do not. I think in isinstance().
Runtime type-checkers operate on live Python objects at call time, not on type stubs at lint time. When a user annotates a function parameter x: SomeComplexType, beartype must evaluate SomeComplexType into a concrete runtime predicate — something it can call against an actual argument value — before or during the first call to that function.
The existing typing module exposes a fixed vocabulary of special forms: Union, Optional, Annotated, Literal, ClassVar, etc. Every runtime type-checker contains a large isinstance(hint, ...) dispatch table mapping these special forms to runtime checking strategies. This table is finite. Its size is bounded by the vocabulary of typing.
PEP 827 expands that vocabulary substantially. The question for runtime type-checker implementors is not "is this a good idea?" (it is), but rather:
- Which new special forms require runtime dispatch support?
- What is the runtime semantic of each new form?
- What breaks in existing annotation-extraction code?
This document answers those three questions. It does not answer questions about static type inference, type-theoretic soundness, or whether TypeScript was a good language to draw inspiration from. (It was not. But here we are.)
Before introducing PEP 827's additions, we must establish what a runtime type-checker does with an annotation today.
Given a function f, beartype extracts annotations via one of three mechanisms, in priority order:
- PEP 649 /
__annotate__: Callf.__annotate__(Format.VALUE)to get adict[str, Any]of evaluated annotations. (Python 3.14+) typing.get_type_hints(f): Resolves string annotations againstf's global and local namespaces. (Python 3.0–3.13)f.__annotations__: Raw unevaluated strings or already-evaluated objects. Unreliable. Used as a fallback.
The result is a mapping from parameter name to a type hint object — an instance of some class defined in typing, types, or user space.
Every type hint object falls into one of these concrete runtime categories:
| Category | Example | Runtime test |
|---|---|---|
| Bare type | int, str, MyClass |
isinstance(hint, type) |
| Parameterized generic | list[int], dict[str, int] |
isinstance(hint, types.GenericAlias) |
typing special form |
Union[int, str], Optional[float] |
isinstance(hint, typing._SpecialForm) |
typing generic alias |
List[int], Callable[[int], str] |
isinstance(hint, typing._GenericAlias) |
Annotated |
Annotated[int, validator] |
get_origin(hint) is Annotated |
TypeVar / ParamSpec |
T, P |
isinstance(hint, TypeVar) |
| String (forward reference) | "MyClass" |
isinstance(hint, str) |
None |
None |
hint is None |
Each category triggers a different code path in beartype's @beartype decorator. PEP 827 adds new categories to this table.
PEP 827 introduces a ContextVar named special_form_evaluator in the typing module. Its signature:
from contextvars import ContextVar
from typing import _GenericAlias, Any
special_form_evaluator: ContextVar[
Callable[[_GenericAlias], Any] | None
] = ContextVar('special_form_evaluator', default=None)This hook is called:
- When
__bool__is called on a Boolean Operator instance (e.g.,IsAssignable[T, S]) - When
__iter__is called on atyping.Iterinstance
Without this hook set, all Boolean Operators return False and Iter yields nothing. This is the safe default that preserves backward compatibility with code that does not register an evaluator.
For runtime type-checkers, this hook is the integration point. A framework that wants to evaluate PEP 827 type operators at runtime installs its evaluator before evaluating annotations:
token = typing.special_form_evaluator.set(my_runtime_evaluator)
try:
result = typing.get_type_hints(f)
finally:
typing.special_form_evaluator.reset(token)The evaluator receives a _GenericAlias instance and returns whatever the calling context requires (a bool, an iterator of types, etc.).
PEP 827 introduces operators in three functional groups. We address them in ascending order of complexity.
These operators extract information from existing types.
Members[T] returns a tuple of Member instances — one per attribute of T, where T is a class, Protocol, or TypedDict. Attrs[T] is identical but excludes methods (callable members).
At runtime, a type-checker evaluating Members[SomeClass] must:
- Introspect
SomeClass.__annotations__(forTypedDictand dataclass-like objects) ortyping.get_type_hints(SomeClass)(for resolved annotations). - Construct
Memberinstances from the introspected attributes. - Return a
tuple[Member, ...].
The Member type encodes:
| Attribute | Type | Meaning |
|---|---|---|
.name |
str |
Attribute name as a Literal[str] |
.type |
type | Declared type of the attribute |
.quals |
tuple | Qualifiers: Required, NotRequired, ReadOnly, ClassVar, Final |
.init |
InitField or None |
Default value / field descriptor |
.definer |
type | The class that declares this member |
GetMember[T, S] retrieves the single Member whose .name is the Literal[str] type S. GetMemberType[T, S] retrieves only that member's .type.
At runtime, these are dictionary lookups over the result of Members[T].
GetArg[T, Base, Idx] extracts the type argument at index Idx (a Literal[int]) from the parameterization of T with respect to generic base class Base.
Example: Given T = dict[str, int] and Base = dict and Idx = Literal[1], GetArg[T, Base, Idx] returns int.
At runtime, this maps to typing.get_args(T)[Idx] after verifying typing.get_origin(T) is assignable to Base.
FromUnion[T] decomposes Union[A, B, C] into the tuple type tuple[A, B, C].
At runtime: typing.get_args(T) when typing.get_origin(T) is Union.
These operators synthesize new types from component parts.
NewProtocol[*Members] constructs a new Protocol class with the attributes described by its Member arguments.
At runtime, this is equivalent to dynamically defining:
class _GeneratedProtocol(Protocol):
attr1: type1
attr2: type2
...The runtime type-checker must cache generated protocols keyed by their Member tuple to avoid O(n) protocol re-instantiation on every type check.
NewTypedDict[*Members] constructs a new TypedDict from Member instances. At runtime, this is equivalent to:
_GeneratedTypedDict = TypedDict('_GeneratedTypedDict', {
member.name: member.type
for member in members
})UpdateClass[*Members] modifies an existing nominal class by injecting new members. This is the most complex construction operator because it mutates class state. Runtime type-checkers must invalidate any cached type-checking wrappers for classes modified by UpdateClass.
Boolean operators produce type-level truth values. They are the conditionals in PEP 827's conditional type syntax.
IsAssignable[T, S] is Literal[True] if T is assignable to S (i.e., every instance of T satisfies S), Literal[False] otherwise.
At runtime, a type-checker implements this as a subtype check. For beartype, this is equivalent to asking: "would beartype's type-check wrapper for parameter type S accept a value of type T?"
Implementation: Runtime evaluation requires a full subtype oracle. For simple cases (isinstance(T, type) and issubclass(T, S)), this is trivial. For Union, Protocol, TypedDict, Annotated, and generic aliases, this requires the same dispatch logic the type-checker already implements for validation — just inverted: checking types against types rather than values against types.
IsEquivalent[T, S] is Literal[True] iff IsAssignable[T, S] and IsAssignable[S, T] are both Literal[True].
Bool[T] is Literal[True] if T is Literal[True], Literal[False] if T is Literal[False], and a type error otherwise. This operator exists to convert the output of IsAssignable into a form usable in conditional type expressions.
PEP 827 introduces three new syntactic patterns in annotations. These patterns use existing Python grammar (if/else expressions, generator comprehensions, attribute access) in positions where type-checkers previously only expected type expressions.
def f(x: int if IsAssignable[T, int] else str) -> None: ...This is a standard Python conditional expression (a if b else c) appearing in an annotation context. The b position holds a Boolean Operator. Python's grammar accepts this without modification. Python's evaluator executes it as a normal bool() call on the Boolean Operator instance — which is where special_form_evaluator is consulted.
Runtime impact: When beartype extracts annotations via __annotate__(Format.VALUE) or get_type_hints(), conditional expressions in annotations are evaluated eagerly. The result is one of the two branch types, not a conditional type object. The conditional type collapses to a concrete type before beartype sees it.
Exception: If annotations are stored as strings (PEP 563 / from __future__ import annotations) or as AST nodes (PEP 649 Format.AST), the conditional expression is not evaluated eagerly. In that case, beartype receives either a raw string or an AST node representing the conditional and must evaluate it explicitly, consulting special_form_evaluator as needed.
def f(x: Union[*[member.type for member in Iter[Attrs[T]]]]) -> None: ...This is a starred generator expression embedded in a type argument list. Iter[Attrs[T]] produces an iterable of Member instances at type-evaluation time. The comprehension maps each Member to its .type and unpacks the results as arguments to Union.
At runtime, this requires evaluating Iter[Attrs[T]] (calling __iter__ on the Iter instance, which invokes special_form_evaluator), collecting the resulting types, and reconstructing the outer parameterized form (Union[*collected_types]).
Runtime impact: Beartype's annotation extraction code currently assumes that type argument lists contain finite, statically-known types. Comprehension types make type argument lists dynamically computed. Beartype's annotation walker must detect Iter special forms and trigger iterative expansion before traversing the resulting type.
member.name # Produces Literal[str] — the attribute's name as a string literal
member.type # Produces the attribute's declared type
member.quals # Produces a tuple of qualifier types
member.init # Produces the attribute's initializer/defaultThese are standard Python attribute access expressions on Member instances. They have unambiguous runtime semantics: accessing named attributes on objects. No special dispatch is required. The challenge is that member is typically a loop variable bound in a comprehension type expression (§3.2), so evaluation requires comprehension evaluation first.
PEP 827 specifies that all operators lift over unions: when an operator argument is Union[A, B], the operator is applied separately to A and B, and the results are themselves unioned.
Example:
IsAssignable[Union[int, str], float]
= Union[IsAssignable[int, float], IsAssignable[str, float]]
= Union[Literal[True], Literal[False]]
= Literal[True] | Literal[False]
For multi-argument operators, results are computed over the Cartesian product of union members across all arguments.
Runtime impact: Every operator implementation in a runtime type-checker must wrap its core logic with union-lifting. This is a cross-cutting concern. The implementation pattern:
def is_assignable_runtime(T: type, S: type) -> bool:
T_members = get_union_members(T) # returns (T,) for non-unions
S_members = get_union_members(S)
return any(
_is_assignable_atomic(t, s)
for t in T_members
for s in S_members
)PEP 827's new annotation syntax (if/for in annotations) introduces two breakage vectors for runtime annotation extraction.
typing.get_type_hints() evaluates string annotations via eval() in the function's global/local namespace. Conditional type expressions and comprehension type expressions are valid Python expressions, so eval() executes them. Boolean Operators' __bool__ and Iter's __iter__ are invoked during this evaluation.
Without special_form_evaluator set, IsAssignable[T, S].__bool__() returns False and Iter[X].__iter__() yields nothing. The conditional type always collapses to the else branch. Comprehension types always produce empty tuples.
This is silent data loss, not an exception. Runtime type-checkers that call get_type_hints() without setting special_form_evaluator receive structurally valid but semantically wrong type hints when PEP 827 operators appear in annotations.
Mitigation: Runtime type-checkers must set special_form_evaluator before calling get_type_hints().
PEP 649 (Python 3.14) adds __annotate__(Format.AST), which returns annotation values as AST nodes rather than evaluated Python objects. AST nodes are inert — they are never executed during extraction. This eliminates the silent-data-loss problem.
Runtime type-checkers that adopt Format.AST as their primary annotation extraction mechanism receive unevaluated AST trees for all annotations, including PEP 827 conditional and comprehension type expressions. They then evaluate these trees themselves under their own evaluation context, calling special_form_evaluator explicitly.
Recommendation for runtime type-checker implementors: Adopt Format.AST as the primary annotation extraction path on Python 3.14+. Retain get_type_hints() as the fallback for Python 3.13 and below, and register special_form_evaluator unconditionally before any annotation extraction call.
The following work items are necessary to support PEP 827 annotations in beartype:
| Item | Description |
|---|---|
special_form_evaluator registration |
Install beartype's type evaluation engine as the special_form_evaluator context variable before every annotation extraction call. |
Members / Attrs dispatch |
Add Members and Attrs to beartype's hint dispatch table. Implement runtime member enumeration via get_type_hints() + __annotations__ introspection. |
GetArg / GetArgs dispatch |
Map these to typing.get_args() with index bounds checking. |
FromUnion dispatch |
Map this to typing.get_args() with get_origin(T) is Union guard. |
IsAssignable dispatch |
Implement a runtime subtype oracle. This is the largest single work item. |
Bool dispatch |
Trivial: identity check against Literal[True] / Literal[False]. |
NewProtocol dispatch |
Dynamically synthesize Protocol subclasses. Cache by structural identity. |
NewTypedDict dispatch |
Dynamically synthesize TypedDict subclasses. Cache by structural identity. |
Iter dispatch |
Register __iter__ handler in special_form_evaluator. Return iterator of evaluated type members. |
| Union lifting | Wrap all operator dispatch in Cartesian-product union lifting. |
Format.AST adoption |
On Python 3.14+, use __annotate__(Format.AST) as primary extraction path. |
| Comprehension type walker | Add a recursive AST/type walker that detects starred comprehension arguments and expands them before type traversal. |
UpdateClass cache invalidation |
Invalidate beartype's per-class wrapper cache when UpdateClass modifies a class. |
Items are ordered by implementation dependency: each item depends on the items listed before it.
PEP 827 is larger than it looks. Its surface area — new special forms in typing — is straightforward. Its depth — the special_form_evaluator hook, union lifting semantics, the interaction between comprehension types and annotation extraction, and the runtime subtype oracle required for IsAssignable — is substantial.
Runtime type-checkers that ignore PEP 827 operators will silently produce incorrect type checks for any annotation that uses conditional or comprehension type expressions. The failure mode is not an exception. It is silent acceptance of wrong types.
The correct response is implementation, not avoidance. I will now go back to my cottage and explain this to my cats. They are hairy. They are full of insects. They are more receptive to type theory than I am.
Source: PEP 827 – Type Manipulation