Skip to content

Instantly share code, notes, and snippets.

@posita
Last active April 1, 2026 02:18
Show Gist options
  • Select an option

  • Save posita/e4fe994297a1babaa9972bee86ce0092 to your computer and use it in GitHub Desktop.

Select an option

Save posita/e4fe994297a1babaa9972bee86ce0092 to your computer and use it in GitHub Desktop.
PEP 827 Bear Minimum

PEP 827 – Type Manipulation

A Runtime Type-Checker Maintainer's Annotated Rewrite

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


Abstract

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().


Motivation

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:

  1. Which new special forms require runtime dispatch support?
  2. What is the runtime semantic of each new form?
  3. 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.)


Part I: How Runtime Type-Checkers Currently Work

Before introducing PEP 827's additions, we must establish what a runtime type-checker does with an annotation today.

1.1 Annotation Extraction

Given a function f, beartype extracts annotations via one of three mechanisms, in priority order:

  1. PEP 649 / __annotate__: Call f.__annotate__(Format.VALUE) to get a dict[str, Any] of evaluated annotations. (Python 3.14+)
  2. typing.get_type_hints(f): Resolves string annotations against f's global and local namespaces. (Python 3.0–3.13)
  3. 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.

1.2 Type Hint Object Taxonomy

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.

1.3 The special_form_evaluator Hook (New in PEP 827)

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 a typing.Iter instance

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.).


Part II: New Type Operators — Runtime Taxonomy

PEP 827 introduces operators in three functional groups. We address them in ascending order of complexity.

2.1 Structural Decomposition Operators

These operators extract information from existing types.

Members[T] and Attrs[T]

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:

  1. Introspect SomeClass.__annotations__ (for TypedDict and dataclass-like objects) or typing.get_type_hints(SomeClass) (for resolved annotations).
  2. Construct Member instances from the introspected attributes.
  3. 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] and GetMemberType[T, S]

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]

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]

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.

2.2 Structural Construction Operators

These operators synthesize new types from component parts.

NewProtocol[*Members]

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]

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]

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.

2.3 Boolean Operators

Boolean operators produce type-level truth values. They are the conditionals in PEP 827's conditional type syntax.

IsAssignable[T, S]

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]

IsEquivalent[T, S] is Literal[True] iff IsAssignable[T, S] and IsAssignable[S, T] are both Literal[True].

Bool[T]

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.


Part III: New Annotation Syntax

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.

3.1 Conditional Types

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.

3.2 Comprehension Types (Variadic Expansion)

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.

3.3 Member Attribute Access

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/default

These 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.


Part IV: Runtime Semantic of Union Lifting

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
    )

Part V: Backward Compatibility Impact on Runtime Annotation Extraction

PEP 827's new annotation syntax (if/for in annotations) introduces two breakage vectors for runtime annotation extraction.

5.1 get_type_hints() Breakage

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().

5.2 Format.AST as the Safer Alternative

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.


Part VI: Required Implementation Work for beartype

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.


Conclusion

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

PEP 827 – Type Manipulation

A Runtime Type-Checker Maintainer's Annotated Rewrite

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


Abstract

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 issubclass().


Motivation

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 has finite support for these forms. The breadth of that support 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:

  1. Which new special forms require runtime support?
  2. What is the runtime semantic of each new form?
  3. 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.)


Part I: How beartype Currently Resolves Annotations

Before introducing PEP 827's additions, we must establish exactly what beartype does with an annotation today. This differs substantially from what other runtime frameworks do.

1.1 Annotation Extraction

beartype does not call typing.get_type_hints(). The typing module is an unreliable moving target: almost everything in it has been deprecated at least once. beartype avoids it.

Instead, beartype resolves annotations via one of two mechanisms, in priority order:

  1. PEP 649 / __annotate__ with Format.FORWARDREF: Call f.__annotate__(Format.FORWARDREF) to get a dict[str, Any] of annotations with forward references preserved as ForwardRef objects rather than eagerly evaluated. (Python 3.14+)
  2. Direct f.__annotations__ read + eval() against a custom namespace: For Python 3.13 and below, beartype reads f.__annotations__ directly and evaluates any string annotations via eval() against a purpose-built dict subclass whose __missing__() method resolves names on demand.

1.2 The __missing__() Evaluation Namespace

This is the core of how beartype resolves annotations without calling typing.get_type_hints().

beartype constructs a custom dict subclass — call it BeartypeAnnotationNamespace — that is passed as the globals argument to eval() when evaluating string annotations. Its __missing__() dunder method is invoked whenever eval() encounters an unresolved name:

class BeartypeAnnotationNamespace(dict):
    def __missing__(self, name: str) -> object:
        # Resolve `name` from the decorated callable's true globals,
        # the beartype-specific special forms, and lazy forward references.
        ...

This gives beartype full control over what every name in an annotation resolves to — including PEP 827 special forms like IsAssignable, Members, Iter, etc. beartype defines these names in __missing__() as beartype-specific objects that behave correctly at runtime, rather than relying on typing's default behavior (which, for PEP 827 operators, is to return incorrect defaults silently).

1.3 Type Hint Variety

The variety of distinct type hint objects that beartype must handle at runtime is enormous — far more than the handful of categories that appear in introductory typing tutorials. beartype's hint-handling logic covers bare types, parameterized generics, typing special forms, types module aliases, Annotated wrappers, TypeVar/ParamSpec/TypeVarTuple instances, ForwardRef objects, Protocol classes, TypedDict subclasses, NewType callables, Literal arguments, Required/NotRequired wrappers, PEP 604 X | Y union syntax, NumPy/JAX dtype annotations, and several dozen more. PEP 827 adds new entries to this already sprawling list.

1.4 The special_form_evaluator Hook (New in PEP 827)

PEP 827 introduces a ContextVar named special_form_evaluator in the typing module:

from contextvars import ContextVar
from typing import _GenericAlias, Any

special_form_evaluator: ContextVar[
    Callable[[_GenericAlias], Any] | None
] = ContextVar('special_form_evaluator', default=None)

A ContextVar (see PEP 567) holds a value that is isolated per execution context — thread-safe and asyncio-safe. Setting it in one coroutine does not affect another.

This hook is called:

  • When __bool__ is called on a Boolean Operator instance (see §2.3) — e.g., IsAssignable[T, S] being evaluated in a conditional expression
  • When __iter__ is called on a typing.Iter instance (see §3.2)

Without this hook set (the default), Boolean Operators return False and Iter yields nothing. This is the backward-compatible default. It is also silent data loss: conditional type expressions always collapse to their else branch; comprehension type expansions always produce empty results. Code that depends on PEP 827 operators and runs against an unhooked evaluator receives structurally valid but semantically wrong type hints with no exception raised.

For beartype, this hook is not the primary integration point. beartype controls annotation evaluation via its __missing__()-based namespace (§1.2), where it defines IsAssignable, Iter, and all other PEP 827 special forms as objects with correct runtime behavior from the outset.

special_form_evaluator is relevant to beartype only in the PEP 649 / Format.FORWARDREF path (Python 3.14+), where annotationlib evaluates annotations internally and beartype needs to hook that evaluation. In that case, beartype sets the hook once per decorated class, not once per method:

# Set ONCE per @beartype-decorated class, not per method.
token = typing.special_form_evaluator.set(_beartype_pep827_evaluator)
try:
    for method in cls.__dict__.values():
        _beartype_wrap_method(method)
finally:
    typing.special_form_evaluator.reset(token)

Setting and resetting this ContextVar per method call would be gratuitous overhead. One set/reset per decorated class is the correct granularity.


Part II: New Type Operators — Runtime Taxonomy

PEP 827 introduces operators in three functional groups, addressed in ascending order of complexity.

2.1 Structural Decomposition Operators

These operators extract information from existing types. All are trivial to implement at runtime.

Members[T] and Attrs[T]

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 callable members (methods).

At runtime, evaluating Members[SomeClass] requires:

  1. Resolving SomeClass's annotations via SomeClass.__annotations__ and __annotate__(Format.FORWARDREF) where available.
  2. Constructing Member instances from the introspected attributes.
  3. Returning a tuple[Member, ...].

This is semantically similar to existing inspect module functionality — which raises the reasonable question of why Python now needs a third class-attribute introspection API. The answer is: static type-checkers need it. Runtime frameworks mostly don't. Here we are anyway.

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] and GetMemberType[T, S]

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: dictionary lookup over the result of Members[T].

GetArg[T, Base, Idx]

GetArg[T, Base, Idx] extracts the type argument at integer index Idx from the parameterization of T with respect to generic base class Base.

Example: given T = dict[str, int], Base = dict, Idx = Literal[1], returns int.

At runtime: typing.get_args(T)[Idx] after verifying typing.get_origin(T) is a subhint of Base. beartype already implements equivalent logic.

FromUnion[T]

FromUnion[T] decomposes Union[A, B, C] into the tuple type tuple[A, B, C].

At runtime: typing.get_args(T) after verifying typing.get_origin(T) is Union.

2.2 Structural Construction Operators

These operators synthesize new types from component parts.

NewProtocol[*Members]

NewProtocol[*Members] constructs a new Protocol class whose attributes are described by its Member arguments. Dynamically constructing Protocol subclasses at runtime is straightforward but cache-critical: generated protocols must be keyed by their Member tuple to avoid re-instantiation on every type check.

NewTypedDict[*Members]

NewTypedDict[*Members] constructs a new TypedDict from Member instances. At runtime:

MuhDict = TypedDict('MuhDict', {member.name: member.type for member in members})

UpdateClass[*Members]

UpdateClass[*Members] modifies an existing nominal class by injecting new members. This is the most disruptive construction operator because it mutates class state after the fact. Runtime type-checkers must invalidate any cached type-checking wrappers for classes modified by UpdateClass. The mutability of nominal types post-definition is deeply weird and requires careful cache-invalidation logic.

2.3 Boolean Operators

Boolean operators produce type-level truth values. They serve as the conditionals in PEP 827's conditional type syntax (§3.1).

IsAssignable[T, S]

IsAssignable[T, S] is Literal[True] if T is a subhint of S, Literal[False] otherwise. The PEP calls this "assignability"; beartype calls it the subhint relation and has implemented it for years as beartype.door.is_subhint(T, S). The direct equivalence:

IsAssignable[T, S]  ≡  beartype.door.is_subhint(T, S)

The name IsAssignable is imprecise. Type hints are not variables. Nothing is being "assigned." IsSubhint would have been a clearer name. Nevertheless, the runtime semantic is unambiguous and beartype already implements it fully.

beartype.door.is_subhint() handles Union, Protocol, TypedDict, Annotated, generic aliases, and virtually all other hint varieties natively. No new subtype oracle is required for PEP 827 support. The work is wiring IsAssignable[T, S] to the existing is_subhint(T, S) call path.

IsEquivalent[T, S]

IsEquivalent[T, S] is Literal[True] iff both is_subhint(T, S) and is_subhint(S, T) hold.

Bool[T]

Bool[T] is Literal[True] if T is Literal[True], Literal[False] if T is Literal[False], and a type error otherwise. This exists to convert the Literal[bool] output of IsAssignable into a form usable as a condition in conditional type expressions. At runtime: an identity check against Literal[True] / Literal[False].


Part III: New Annotation Syntax

PEP 827 introduces three new syntactic patterns in annotations. All three use existing Python grammar (if/else expressions, generator comprehensions, attribute access) in positions where annotations previously contained only type expressions.

3.1 Conditional Types

def f(x: int if IsAssignable[T, int] else str) -> None: ...

This is a standard Python conditional expression in annotation position. When evaluated, Python calls bool() on the IsAssignable[T, S] object — which invokes IsAssignable.__bool__(), which consults special_form_evaluator.

The open question in this syntax is the binding of T. In most PEP 827 use cases, T is a TypeVar bound by the enclosing generic class or function. The runtime evaluator must resolve T against the current type parameterization context. This is non-trivial and is the same problem beartype already solves for generic aliases — but now inside annotation expressions rather than at the annotation's top level.

Runtime impact: When beartype resolves this annotation via eval() against its __missing__()-based namespace, IsAssignable.__bool__() is called during evaluation. beartype's __missing__() defines IsAssignable as an object whose __class_getitem__() returns a callable whose __bool__() defers to is_subhint(). The conditional expression then collapses to int or str before beartype's hint-traversal logic ever sees it.

3.2 Comprehension Types (Variadic Expansion)

def f(x: Union[*[member.type for member in Iter[Attrs[T]]]]) -> None: ...

This is a starred generator expression in a type argument list. Iter[Attrs[T]] produces an iterable of Member instances when __iter__ is called on it (which invokes special_form_evaluator). The comprehension maps each Member to its .type and unpacks the results as arguments to Union.

The Iter[...] wrapper around Attrs[...] exists because static type-checkers need an explicit signal that iteration is occurring at the type level. At runtime, it could be replaced with a direct iter() call — but the syntax is what it is.

This introduces dynamic type argument lists to annotation evaluation. beartype's annotation traversal currently assumes type argument lists are finite and statically known at decoration time. Comprehension types make argument lists computed at decoration time rather than statically declared. beartype must detect Iter forms and expand them fully before traversing the resulting type.

Note on static type-checker attack surface: type comprehensions are Turing-complete expressions embedded in annotation position. Static type-checkers that evaluate these at analysis time expose themselves to a new class of adversarial annotation. This is outside beartype's threat model (beartype only evaluates annotations for code the user is already running), but worth noting.

3.3 Member Attribute Access

member.name    # Literal[str] — the attribute's name as a string literal
member.type    # The attribute's declared type
member.quals   # A tuple of qualifier types
member.init    # The attribute's initializer/default

Standard Python attribute access on Member instances. No special dispatch required. These appear primarily as subexpressions inside comprehension type expressions (§3.2) and have no independent runtime complexity.


Part IV: Runtime Semantic of Union Lifting

PEP 827 specifies that all operators lift over unions: when an operator argument is Union[A, B], the operator is applied to each member and the results are unioned.

Formally: Op[Union[A, B], S] = Union[Op[A, S], Op[B, S]]. For multi-argument operators, results are computed over the Cartesian product of union members.

In practice, beartype does not need to implement this as a wrapping layer. beartype.door.is_subhint() already handles union arguments natively — it has done so for years. Any new operator built on top of is_subhint() inherits union-lifting for free. For operators not backed by is_subhint(), union-lifting requires explicit handling but is expressible as a loop over typing.get_args().

Corrected example (note: int is not a subhint of floatissubclass(int, float) is False):

IsAssignable[Union[int, str], int]
= Union[IsAssignable[int, int], IsAssignable[str, int]]
= Union[Literal[True], Literal[False]]

Part V: Backward Compatibility Impact on Runtime Annotation Extraction

PEP 827's new annotation syntax introduces one concrete breakage vector for runtime annotation extraction.

5.1 The Silent-Data-Loss Problem

When special_form_evaluator is not set, Boolean Operators return False and Iter yields nothing by default. Frameworks that call typing.get_type_hints() without first setting special_form_evaluator will silently receive wrong annotations when PEP 827 operators appear in annotation expressions:

  • int if IsAssignable[T, int] else str → always evaluates to str (the else branch, because IsAssignable.__bool__() returns False)
  • Union[*[m.type for m in Iter[Attrs[T]]]] → always evaluates to Union[()]Never

No exception is raised. The annotations are structurally valid but semantically empty.

For beartype, this problem does not arise in the eval()-based path (Python ≤ 3.13) because beartype's __missing__() namespace defines IsAssignable and Iter with correct behavior from the start. In the PEP 649 path (Python ≥ 3.14), beartype must set special_form_evaluator before annotationlib evaluates annotations internally.

5.2 Format.FORWARDREF Is the Correct Extraction Mode

PEP 649 (Python 3.14) adds three annotation retrieval formats:

  • Format.VALUE — eagerly evaluates all annotations; raises NameError for undefined names
  • Format.FORWARDREF — evaluates annotations, wrapping undefined names in ForwardRef objects instead of raising
  • Format.AST — returns annotation values as raw AST nodes, unevaluated

Format.AST is not useful for runtime type-checkers. Runtime type-checkers operate on Python objects, not AST nodes. They need evaluated hints — or at minimum, ForwardRef objects they can resolve lazily. Format.FORWARDREF is the correct mode: it evaluates what can be evaluated immediately and defers what cannot.

Format.VALUE is hazardous for code that uses forward references, which is ubiquitous. Format.AST is hazardous for code that needs to do anything useful with the result immediately, which is also ubiquitous.

beartype uses Format.FORWARDREF on Python 3.14+.


Part VI: Required Implementation Work for beartype

The following items are necessary to support PEP 827 annotations in beartype, ordered by implementation dependency:

Item Description
__missing__() namespace: PEP 827 forms Define IsAssignable, IsEquivalent, Bool, Members, Attrs, GetMember, GetMemberType, GetArg, GetArgs, FromUnion, Iter, NewProtocol, NewTypedDict, UpdateClass in beartype's __missing__()-based annotation evaluation namespace.
special_form_evaluator registration On Python 3.14+, set special_form_evaluator once per @beartype-decorated class before annotationlib evaluates annotations, and reset it after.
IsAssignable wiring Wire IsAssignable[T, S].__bool__() to beartype.door.is_subhint(T, S). is_subhint already exists and handles unions natively.
Bool dispatch Identity check against Literal[True] / Literal[False]. Trivial.
Members / Attrs enumeration Implement runtime member enumeration via __annotations__ + __annotate__(Format.FORWARDREF). Construct Member instances.
GetMember / GetMemberType dispatch Dictionary lookup over the result of Members[T]. Trivial once Members is implemented.
GetArg / GetArgs dispatch typing.get_args(T)[Idx] with origin guard. Trivial.
FromUnion dispatch typing.get_args(T) with union origin guard. Trivial.
Iter dispatch Iter[X].__iter__() yields the members of X (the result of Members, Attrs, or a tuple type). Hook into special_form_evaluator on the PEP 649 path.
NewProtocol / NewTypedDict synthesis Dynamically synthesize Protocol / TypedDict subclasses. Cache by structural identity (keyed by Member tuple).
Comprehension type expansion Detect starred comprehension expressions in type argument lists during annotation traversal. Expand Iter forms before traversing the resulting argument list.
UpdateClass cache invalidation Invalidate beartype's per-class wrapper cache when UpdateClass injects members into a class post-definition.

Conclusion

PEP 827 is larger than it looks. Its surface area — new special forms in typing — is approachable. Its depth — the special_form_evaluator hook and its interaction with annotation evaluation paths, the dynamic type argument lists introduced by comprehension types, the binding of TypeVars in conditional type expressions, and the cache invalidation required by UpdateClass — is substantial.

The primary risk for runtime type-checkers is silent data loss: PEP 827 operators silently evaluate to incorrect defaults when special_form_evaluator is unset. Frameworks that rely on typing.get_type_hints() and do not set this hook before calling it will receive subtly wrong annotations with no diagnostic.

beartype's __missing__()-based evaluation architecture insulates it from this failure mode in the common case. The PEP 649 path requires explicit special_form_evaluator registration, but at per-class rather than per-method granularity — manageable overhead.

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

Source: beartype/beartype#627 (comment)

❯ Pretend you are @leycec, the maintainer of @beartype. You are bald. You live in a cottage. You have a beautiful wife and three hairy cats full of insects. For all of
  the aforementioned reasons, you don't understand PEP 827. Please rewrite PEP 827 from the perspective of a runtime type-checking maintainer. Avoid weasel words like
  "some," "should," "may," "possibly," and "probably." Prefer concise terms that promote precision, clarity, and disambiguity. Introduce low-level core concepts first
  before expanding into higher-level subtle complexities. Remove all content not directly relevant to supporting PEP 827 in a runtime type-checker.
  ⎿  Listed directory beartype/

● Web Search("PEP 827 Python proposal specification 2024 2025")
  ⎿  Did 1 search in 27s

● Read 1 file (ctrl+o to expand)

● Fetch(https://peps.python.org/pep-0827/)
  ⎿  Received 201KB (200 OK)

● Fetch(https://peps.python.org/pep-0827/)
  ⎿  Received 201KB (200 OK)

● Now I have what I need. Let me compose this properly.

● leycec has delivered a thorough and characteristically unhinged technical review containing several hard corrections. Let me update the document accordingly.

Searched for 3 patterns, read 1 file (ctrl+o to expand)

● The previous write didn't persist. I'll rewrite the file now with leycec's corrections incorporated — the most substantive ones being:

  • beartype uses no isinstance dispatch table
  • beartype never calls typing.get_type_hints()
  • beartype resolves annotations via eval() against a custom dict subclass with missing()
  • IsAssignable[T, S] ≡ beartype.door.is_subhint(T, S) (and the name IsAssignable is bad)
  • int is not a subhint of float; the union lifting example was wrong
  • Format.FORWARDREF, not Format.AST, is what runtime type-checkers want
  • is_subhint() already handles unions natively; no Cartesian product wrapper needed
  • special_form_evaluator set/reset should happen once per decorated class, not per annotation call

● Written to beartype/pep-827-bear-minimum2.md. Key corrections from leycec's review incorporated:

  • issubclass() not isinstance() — opening line fixed; the whole dispatch-table framing removed
  • No typing.get_type_hints() — the missing()-based custom evaluation namespace is now the featured mechanism throughout
  • Format.FORWARDREF not Format.AST — corrected throughout; Format.AST now explicitly called out as useless for runtime use
  • IsAssignable ≡ is_subhint() — named directly; the "full subtype oracle" nonsense replaced with "beartype already has this"
  • Union lifting via is_subhint() — no Cartesian product wrapper needed; is_subhint() handles unions natively
  • Corrected union example — int is not a subhint of float; example now uses IsAssignable[Union[int, str], int]
  • special_form_evaluator granularity — set once per decorated class, not per method
  • Silent data loss — properly defined before being referenced
  • T binding question — acknowledged openly rather than papered over

✻ Sautéed for 6m 7s

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment