runtime type-soundness checks¶
a type checker is only sound up to the assumptions it makes. some of those
assumptions are pure annotation-level claims with no runtime backing — a
def t[T]() -> T promises to return a T, but nothing at runtime enforces
it. basedpython inserts a runtime isinstance guard at each point where a
value crosses from that unverified world into a typed slot:
transpiles to (targeting python 3.12):
def _soundness_check(_v, _t):
if not isinstance(_v, _t):
raise TypeError(
f"type soundness violation: expected {getattr(_t, '__name__', _t)}, "
f"got {type(_v).__name__}"
)
return _v
def t[T]() -> T: ...
def f():
a: str = _soundness_check(t(), str)
if t() returns something that isn't a str, the program raises a
TypeError at the point the lie enters, instead of failing later somewhere
unrelated (or not at all)
positions¶
a check is inserted at these syntactic positions, each independently toggleable:
| position | validates | example |
|---|---|---|
generic-calls |
results of calls whose type is typevar-derived | d.get(k) on a dict[str, int] |
projections |
element reads out of a specialized container | a[0] on an a: list[str] |
iterations |
loop / comprehension elements | for x in a on a: list[str] |
assignments |
dynamic flowing into an annotated binding |
a: str = dyn_val |
returns |
a returned value against the declared return | def g() -> str: return dyn_val |
arguments |
a call argument against its parameter annotation | takes(dyn_val) — takes(s: str) |
parameters |
a function's own parameters, at entry | def f(a: A[int]): ... |
the first six defend against gaps in ty's own inference and are on by default.
parameters is different — it validates a function's parameters inside the
function body, defending its contract against callers the checker never saw
(untyped or third-party code that imports and calls it):
with parameters enabled, f transpiles to:
now f(x) raises if x isn't really an A[int], no matter who calls it or
whether they suppressed the error with # type: ignore. because it runs on
every call, parameters is off in the default set — opt in with
--soundness all or --soundness parameters (see configuration).
guards are placed after any docstring; self, unannotated, *args/**kwargs,
and untestable-typed parameters are skipped.
iteration wraps the iterable in a generator (_soundness_iter, or
_soundness_aiter for async for) that validates each element as it is
produced:
→ for x in _soundness_iter(a, str):
what gets a check¶
the second argument is a shallow isinstance target derived from the
inferred type. the check is deliberately shallow — list[str] validates as
list; the element claim is validated at its own projection sites, not by
walking the whole container:
| inferred type | check target |
|---|---|
str |
str |
int \| None |
(int, type(None)) |
list[str] |
list |
| a user class | the class name |
no check is emitted when there is no faithful shallow runtime test —
protocols, callables, unsolved type parameters, and object (which every
value satisfies) are all skipped, as is a type whose name doesn't resolve at
module scope (a class shadowed by a local binding, or one not in scope where
the check would run). a class the program does not have where the check runs
is skipped the same way: one bound only past a condition that reads
TYPE_CHECKING (how an import cycle is broken) — an if, while or match
on the constant under any name or inside any expression, or a value a
conditional expression on it chooses — one imported only on some paths,
unbound by a del, rebound from a function through global, or declared
further down the module than the check, and one bound in the else: of a
TYPE_CHECKING block, where the name the check would read need not be the
class the checker resolved. a pure-None result is skipped too: validating a
value that carries no data guards nothing
a check in a function or a method runs whenever it is called, which can be
before or after the module reaches a class declared further down. where every
binding the module makes of the name binds that one class — none binds anything
else, none is made only for the checker, nothing unbinds it, and no function
rebinds it through global — the check looks the name up as it runs, and is
made once the name is bound; until then the value passes unchecked. where that
does not hold, the class has to be bound above the def or class statement
at the top of the module that holds the function, which can be called as soon
as that runs:
class Modifier:
cuts: list[Cut]
def total(self) -> int:
total = 0
for cut in self.cuts:
total += cut.size
return total
class Cut:
size: int
→ for cut in _soundness_once_bound(("Cut",), self.cuts, _soundness_iter, lambda: Cut):
the lookup is by the name the class is emitted under, so a private class Cut
is looked up as "_Cut". a check at the module's own level runs in order, so
it is made only below the class it names. by compile leaves a function with
such a check to its interpreted definition
deep checks for user generics¶
for a target that is a user-defined generic specialization (A[int]), the
check validates the type arguments too, not just the base class. python stamps
A[int]() instances with __orig_class__, so the specialization survives to
runtime and can be verified:
class A[T]:
t: T
def f(a: A[int]): ...
def g(x: dynamic): # x is a real A[…], but ty can't see which
f(x)
→ f(_soundness_parametric(x, A[int], (0,)))
if x is an A[bool] (say it came from unchecked third-party code and was
handed to you as dynamic), the check raises rather than letting the wrong
specialization through. the match respects the target's declared variance — an
out T (covariant) parameter accepts a subtype argument.
the arguments are only checked when the value actually carries them: a value
with no __orig_class__ (a bare A(), or an instance from a non-generic
code path) passes the argument check, leaving the base isinstance as the
guarantee — validate what's available, never invent a failure.
builtin collections (list[int], dict[str, int]) erase their type arguments
at runtime, so they keep the shallow base check (list, dict); there is
nothing to probe.
configuration¶
the inference-gap checks are on by default. control which positions are active
with --soundness on by run, by build, and by transpile:
by run main # the default set (no `parameters`)
by run main --soundness all # everything, incl. `parameters`
by run main --soundness none # disable entirely
by run main --soundness parameters # only the entry checks
by run main --soundness returns,arguments # only these two
by transpile main.by --soundness projections # only element reads
the spec is default, all, none, or a comma-separated subset of the
position names in the table above. default is the six inference-gap checks;
all adds the opt-in parameters entry checks. an unknown name is a hard
error, so a typo never silently disables a check you expected
checks are never emitted for stub files (.byi), which don't execute
composition¶
the guards compose with the other basedpython lowerings inside the value they
wrap — a ?? coalesce, a ?. chain, a ! force-unwrap, or a nested
projection all lower normally inside the check:
→ b = _soundness_check(_soundness_check(a[0], list)[1], str)
a value that is already checkable on its own is validated once, at the
innermost position that knows a concrete target — the returns,
assignments, and arguments gates defer to generic-calls / projections
rather than wrapping a second time
limits¶
the checks are conservative — a missed check is a silent no-op, a wrong one
would change behaviour, so anything ambiguous is skipped. there is no check on
await results, unpacking targets, *args / **kwargs spreads, or arguments
to class constructors and overloaded functions. a check argument that names a
class defined later in the module can raise NameError if the guarded line
runs at import time before the class body