parametric type tests¶
isinstance(x, int)). when the right-hand side is a parameterized generic,
x is C[args] means type(x) <: C[args], so it follows C's
variance:
class Box[out T]:
def __init__(self): ...
b: Box[int] = Box[int]()
b is Box[object] # True — Box is covariant, and int <: object
why isinstance can't answer this¶
plain isinstance cannot answer a parameterized test — x is list[int]:
isinstance(x, list[int]) is a runtime TypeError, and the builtins erase
their type arguments anyway. basedpython resolves it from static types at
compile time, keeping a runtime residue only where one is needed
how a test resolves¶
the value's static type decides the strategy:
-
statically provable → folded. when
type(x)is a subtype ofC[args]the test folds toTrue; when the two are disjoint, toFalse. this uses ty's own subtyping, so it respects variance automatically — a covariantBox[int]is aBox[object], an invariantlist[object]is not alist[int]. side effects in the tested expression are preserved -
reified type parameter → token equality. a value typed by a reified type parameter carries the specialization in that parameter's runtime cell, so the test compares cells:
x: TagainstC[args]lowers toT == C[args];x: list[T]againstlist[int]unifies structurally toT == int. the parameter is reified for this. this works against a builtin target too — the cell holds the alias, nothing is erased -
undecidable, user-generic target →
__orig_class__probe. when none of the above apply, the last resort reads the value's__orig_class__— whichA[int](…)stamps on user generics — and matches each type argument by the target's variance. it discriminates a union soundly (x: A[int] | A[str]againstA[int]); answersFalsefor values that carry no__orig_class__ -
undecidable, protocol target → structural check. a protocol's instances record no specialization, but basedpython reifies class attribute annotations, so the value's class is inspected member by member — each reified annotation against the member's specialized type, by that member's variance. a method member is checked the same way through its parameter and return annotations. see below
-
erased union parameter → the call site answers. a parameter typed as a union of specializations of one erased origin (
list[int] | list[str]) cannot be discriminated by looking at the value — both arms are the same C-levellist. the missing fact is not a property of the value but of the binding, so it travels with the call: the parameter is given a reified type parameter and the test compares that cell. see below -
undecidable, builtin target → error. any other runtime
list/dict/set/tuplecarries no record of its type arguments, so it cannot be checked at runtime. this is anerased-type-checkerror — there is deliberately no "check the first element" heuristic, since an empty collection has no element and a builtin's element type is erased
so of the two undecidable cases, only the target distinguishes them:
class A[T]: ...
def f(a: object):
a is A[int] # valid — A's instances carry __orig_class__
a is list[int] # error — a runtime list erases its element type
the target may be spelled through an alias¶
The target need not be a literal subscript. A name bound to a specialization, or a PEP 695 type
alias for one, resolves the same way — x is X behaves exactly like x is A[int]:
class A[T]: ...
X = A[int] # implicit alias
type Y = A[int] # PEP 695 alias
def f(a: object):
a is X # same as `a is A[int]`
a is Y # same as `a is A[int]`
A bare class name that is not a specialization (a is int) stays an ordinary instance test. One
limitation: the reified-cell path spells T == <arg> from the target's
syntax, so it needs a literal subscript — x: T is X through an alias falls back to the fold or the
probe rather than the cell comparison.
variance¶
both the fold and the probe read the effective variance of each type
argument: the declared variance combined with any use-site
projection the target spells. so an invariant
T can be matched covariantly for one test by asking for it:
class A[in out T]:
def __init__(self): ...
def f(a: A[*]):
print(a is A[out int]) # True for an A[bool] — `out` projects T covariantly
print(a is A[int]) # False for an A[bool] — T is invariant unprojected
this is the same combination that decides assignability, so a test can never
contradict the annotation: wherever b: A[out int] = a is accepted,
a is A[out int] is True.
narrowing¶
the positive branch narrows to the tested specialization. the negative
branch does not narrow — a value that carries no __orig_class__ answers
False even when its static type matches, so the test does not prove the
negation:
class A[T]:
def __init__(self, t: T):
self.v: list[T] = [t]
def f(x: A[int] | A[str]):
if x is A[int]:
reveal_type(x) # A[int]
else:
reveal_type(x) # A[int] | A[str]
the erased-type-check error¶
a parametric test against a builtin collection that isn't statically
decidable is reported under the erased-type-check rule:
to make the test work, reify the type parameter (so it compares the cell) or
test against a user-defined generic (whose instances carry __orig_class__):
def f[T](x: T) -> bool:
return x is list[int] # ok — compares the reified `T`
class A[T]: ...
def g(x) -> bool:
return x is A[int] # ok — probes `x.__orig_class__`
=== is unaffected¶
the === / !== identity operators keep python semantics; only the is /
is not keyword form is a type test
requirements¶
a user-generic probe works on any target version. the reified-cell token equality path is only reached inside a reified generic, which already requires python 3.12+
a protocol target is checked structurally¶
a protocol has no identity to record, so there is nothing on the value saying which specialization it satisfies. what there is is the value's class, whose annotations basedpython reifies — so the check is done member by member against those, each by its own variance:
from typing import Protocol
class HasA[T](Protocol):
a: T
class IntAttr:
a: int
def f(x: object):
x is HasA[int] # True for an `IntAttr`
an invariant member is exact and a covariant one follows subtyping, the same way the target's own variance decides every other parametric test. a method member is checked through its annotations too — parameters contravariantly, the return covariantly.
only a class-level annotation survives¶
python records nothing for a self.a: int written inside __init__, so a member
declared there cannot be read back at runtime. the checker still accepts such a
class as satisfying the protocol, so answering False would put the two halves
in contradiction — the test raises instead:
class InitLevel:
def __init__(self) -> None:
self.a: int = 1
InitLevel() is HasA[int] # TypeError: its type is declared inside a method
declare the member in the class body and the check answers. this is the same line the erased builtin target takes: refuse, never guess.
the cast forms usually never get here¶
a checked cast shares this engine, but only for a value the
checker could not already decide. InitLevel() cast? HasA[int] is decided
statically — ty accepts that class as satisfying the protocol — so the cast
folds to a pass-through and no runtime check is emitted at all. the is above
raises only because its value is an object, where nothing is known statically
and the residue has to run.
so the two can differ on the same class: the cast succeeds, the test refuses. both are right for what they were asked, but it is worth knowing the cast is not re-verifying what the checker already concluded.
an erased union parameter carries its specialization¶
A union of specializations of one erased origin is undecidable at runtime — list[int] and
list[str] are the same list, and a builtin rejects the __orig_class__ stamp. Probing the value
answered False for every arm, which is sound (only the positive branch narrows) but useless:
def f(data: list[int] | list[str]):
if data is list[int]: ... # every arm answered False
if data is list[str]: ...
The specialization is a static fact about the binding, known wherever the argument was written. So it travels with the call instead of being asked of the value: the parameter becomes generic over the differing argument, and that type parameter is reified.
def f(data: list[int] | list[str]):
if data is list[int]: # compares the reified cell
print("it's ints")
This is a lowering detail, not surface syntax. The checker keeps seeing the union you declared —
reveal_type(data) is list[int] | list[str], and nothing basedpython reports ever names the
synthesized parameter.
The rewrite is driven by the signature, so a function that only forwards its value is rewritten too and the specialization survives the hop:
def middle(data: list[int] | list[str]):
f(data) # forwards its own cell
middle(list[int]()) # supplies the specialization
when it does not apply¶
- below python 3.12. Reification compiles the type parameter into a PEP 695 closure cell, which needs native type-parameter syntax in the output. On an older target the parameter keeps its union and the test keeps the probe it always had.
- a union the runtime can already discriminate. Different origins (
list[int] | set[int]) are separated byisinstance, and a user-defined generic carries__orig_class__. - more than one differing argument.
dict[str, int] | dict[bool, str]varies in two positions, so no single type parameter stands for the difference. - an argument with no runtime spelling, such as a scope-local class.
when the call site cannot supply it¶
Reaching the function through a value whose type the checker could not pin down — a callable variable, a dynamic value — leaves nothing to supply the specialization. That raises rather than guessing:
TypeError: f() cannot tell which specialization it was given: the argument's
type arguments are erased at runtime, and the call site did not record them
This is the same line the erased builtin target takes: refuse, never guess.