sound types¶
python's gradual guarantee requires a type checker to fall back to a gradual type
(Any / Unknown) whenever an annotation is missing, even when a precise type could be
inferred. adding annotations must never introduce new errors, so an unannotated symbol has to
accept anything
in a fully typed project that trade is all cost and no benefit. it forces an annotation to be written for something the checker already knows, and it silently swallows real mistakes
basedpython deliberately breaks the gradual guarantee and uses the precise type instead. this is
analysis.sound-types, which is on under the default type checking
preset and off under ty-compatible
an explicit annotation always wins over anything inferred by this option
per-module configuration¶
the option is resolved per module, so a project migrating from a gradual checker can turn it off and adopt it a directory at a time
[analysis]
sound-types = false
[[overrides]]
include = ["src/core/**"]
[overrides.analysis]
sound-types = true
the rule is that the module declaring a construct governs how that construct's types are inferred, and consumers see the result whatever their own setting is. so a sound module's signatures stay precise when a gradual module imports them
# src/core/lib.py — sound
def f(a=1) -> None: ...
# src/legacy/main.py — gradual
from src.core.lib import f
f("wrong") # error: `f` is declared in a sound module, so its signature is precise here too
and the reverse holds: a sound module importing a gradual one does not retroactively tighten it
# src/legacy/lib.py — gradual
def g(a=1) -> None: ...
# src/core/main.py — sound
from src.legacy.lib import g
g("fine") # no error: `g` is declared in a gradual module
this applies uniformly. a parameter default, an inherited override signature and a ClassVar are
governed by the module that declares them; a lambda or a collection literal by the module it is
written in; and a call's type-variable solution by the module declaring the callable, so a sound
module never re-interprets a gradual module's generics
unannotated parameters¶
an unannotated parameter is not "some fixed type the checker failed to learn": it is a hole the call
site fills. so it opens an anonymous type parameter named after it — the same hole some spells by
hand — bounded by everything the function requires of it
naming the hole is what keeps what goes in connected to what comes back out. an unannotated identity function really is one
requirements come from three places, and the bound is their intersection. with none of them the
bound is gradual, which is exactly the Unknown an unannotated parameter has always had
a default value¶
a default is a sample of what belongs there, so it bounds the hole by its promoted type — and becomes the hole's PEP 696 default, so a call that omits the argument still names a type
None is the exception, and it is the common one. it is the sentinel every optional parameter is
spelled with — it says the argument may be left out, not that None is the kind of thing that
belongs there — so it bounds nothing, and def f(x=None) still accepts whatever the body allows
def f(a=1):
reveal_type(a) # a@f
reveal_type(f()) # Literal[1]
reveal_type(f(2)) # Literal[2]
f("x") # error: invalid-argument-type
lambdas take the default's promoted type directly rather than opening a hole. a lambda body is a single expression, so there is nothing else for the analysis below to read
a Callable type context still takes priority over the default
what the body does with it¶
a member the body reads is a member the argument has to have, and a method it calls has to be callable the same way — same arity, same keywords. a member that is only read is read-only, so an argument whose own attribute is typed more precisely still fits
a synthesized bound is spelled as the inline protocol that would declare it, so the recovered signature is something you could have written by hand
an operator, a subscript, iteration and calling the parameter itself are members like any other, so each of those is a requirement too
def f(s):
a = s.rstrip("\r\n")
b = s[:5]
# def f(s: some protocol(def __getitem__(self, slice[None, 5, None], /) -> Unknown; def rstrip(self, str, /) -> object))
iterating asks for two members at once — __iter__, and a __next__ on whatever that hands back —
so what a loop body does with the loop variable is a requirement on what the argument yields
def total(xs):
for x in xs:
x.bit_length()
# def total(xs: some protocol(def __iter__(self, /) -> protocol(def __next__(self, /) -> protocol(def bit_length(self, /) -> object))))
only the left operand of a binary operation carries the requirement. python reaches the right
operand's reflected dunder only when the left one returns NotImplemented, and which of the two an
operation takes is decided by the argument, so 2 * x asks nothing of x — see
a bound has to type the body it came from for what
that then means for x
a member the body reached this way reads back as Unknown rather than object, because recording
a requirement is about what the call site has to supply and it should not change what the body
itself reads. so does a member the body named: the requirement is that it exist, and nothing
about naming it says what it holds. object would not describe such a value, it would forbid every
use of it — and that claim travels, because a member's type becomes the recovered return type of the
function that read it
a parameter the argument is forwarded into is a requirement too
and reading a value into somewhere that says what it holds is a requirement on that value. an annotated assignment, a call argument and a declared return type are all such places, and each of them constrains whichever value was read into it — a member, or the parameter itself
def f(x):
a: int = x.foo()
return x
# def f(x: some protocol(def foo(self, /) -> int)) -> x
def g(x) -> str:
return x
# def g(x: some str) -> str
an inferred return type is not one of them: it is read off the body, so it cannot also constrain
it. with nothing to say what a member holds, it only has to exist, and its value is object
what the body does with a member's value is a requirement on that member too, however deep it goes
def f(x):
a = x.foo()
b = a.foo()
assert b is int
# def f(x: some protocol(def foo(self, /) -> protocol(def foo(self, /) -> int)))
uses are recognised by type, not by spelling: a name that was reassigned, or narrowed, is no longer the parameter, and what happens to it afterwards says nothing about the argument. a local that a member's value was given to is read the same way: a name bound more than once cannot stand for one value, and a use under a narrowing is about something narrower than the value it was bound to
saying nothing is not the same as costing nothing. a narrowed parameter still carries the hole, so the body goes on being checked against whatever bound the rest of it recovers — through a use no requirement was ever built from, which is what a bound has to type the body it came from rules out. so a use like that takes the recovered bound away, exactly as the unnarrowed form of it would
a narrowing the bound already implies is not one of these, which is what keeps the assert below
working. once int is x's bound, x narrowed to an int is the hole, so the uses under such an
assert are recorded like any other — and what they record is then checked against the very bound
the assert put there
a parameter its own body rebinds keeps nothing at all, for the same reason. the reads above the rebinding are not enough on their own: walking a linked structure asks only that the argument have the member it walks along, so the rebinding lands on that member — whose value nothing described — and the walk's next step would fail against the signature the function itself produced
an assert at the top of the body¶
an assert there holds for every call that returns normally, so it is the author saying what they
were prepared to accept
the same test inside an if says nothing — the author plainly meant the other branch to be
reachable
a bound has to type the body it came from¶
every requirement above is read off the body, so the body is checked against the bound they add up to. that makes one thing non-negotiable: a bound that the body's own code would fail against is worse than no bound at all, because it makes the checker report an error in the very function it claims to have understood
so a use the analysis cannot state does not get passed over — it takes the bound away
reading r.bit_length() on its own asks for a protocol. 2 * r two lines later is exactly what such
a protocol cannot answer: python reaches r's reflected dunder only when int.__mul__ returns
NotImplemented, and which of the two routes an operation takes is decided by the argument, so there
is nothing here to ask of r. keeping the protocol would make area's own body stop compiling
against area's own signature, so the protocol goes
the same rule applies one member deep: a use nothing can be said about takes away only the value it
is about. x.foo still has to be there, and still has to be callable the way the body called it
what is left out¶
the uses that cannot be stated are the ones where python's own answer is a disjunction, or a shape an inline protocol has no way to write:
- an operand on the right of an operation whose left operand does not take anything, as above
in, which runs through__contains__,__iter__or__getitem__on the containerawait,async for,with,raise,del x.a,match, and**xin a call- writing a member rather than reading one —
x.a = 1 - a call whose arguments are splatted, since no fixed parameter list says how many there are
- an argument whose parameter cannot be worked out, or is one this cannot write down
- anything at all written on a name a test narrowed, unless the position takes anything: the branch was written because the author meant the other one to be reachable, so holding every argument to what this one does would reject the very calls the test exists for
the other side of that rule is what keeps most code unaffected: wherever a position accepts object
it accepts whatever bound the body recovers, so nothing has to be recorded and nothing is lost.
printing a value, formatting one, reading one for its truth, looking a key up in a mapping and
"%s" % x are all positions like that
a forwarded type that mentions a type variable is left out too: it is bound to the callee's own scope, and the same rule stops two functions that forward into each other from each defining the other
a value the body reached through a parameter is left out on the same grounds. reading a member off one leaves the shape this analysis invented for that member, so requiring a method to accept it would be requiring something of the very type being written
class Inner:
def b(self, other: int) -> None: ...
class Outer:
a: Inner
def f(x):
x.a.b(x.a)
f(Outer()) # ok — nothing was required of `b`'s parameter
requirements that cannot all hold fall back to gradual as well. a bound of Never would report the
contradiction at every call site and never where it lives
unannotated return types¶
a function with no return annotation returns what its body returns: the union of every return
expression, plus None when control can also fall off the end
a body with nothing in it — a stub, a protocol member, an abstractmethod — returns None, which
is what running it would do. a body that always raises returns Never
a generator returns a generator: its yield expressions supply the yield type and its return
statements the third type argument. the send type is the one thing the body does not determine, so
it stays gradual
def gen():
yield 1
return "done"
reveal_type(gen()) # GeneratorType[Literal[1], Unknown, Literal["done"]]
a function that calls itself is read from the returns that do not recurse, so fact below returns
int. a body that wraps its own result has no type it settles on — each reading is a constructor
deeper than the last — and the part that grows is marked Divergent rather than chased
def fact(n: int):
if n < 2:
return 1
return n * fact(n - 1)
reveal_type(fact(5)) # int
def recur(a):
return [recur(b) for b in a]
reveal_type(recur([])) # list[Divergent]
what the body established about what it returns¶
a return type is a summary of the function, so it carries what the body settled about the value it hands back — including what narrowing settled about that value's members, which is otherwise left behind on the places it was established on
class Config:
debug: object
def checked(c: Config):
assert isinstance(c.debug, bool)
return c
# def checked(c: Config) -> Config & protocol(debug: bool)
reveal_type(checked(Config()).debug) # bool
(the protocol(...) spelling is basedpython's; in a .py file the same type reads back as
Config & <Protocol with members 'debug'>)
the claim is a read-only one: it says what reading the member gives, which is all the narrowing established. it is exactly the narrowing that was in force where the value was handed back — writing to the member, or narrowing it on only one branch, leaves nothing to carry
def rewritten(c: Config):
assert isinstance(c.debug, bool)
c.debug = "on"
return c
# def rewritten(c: Config) -> Config
being the same claim, it rests on the same thing narrowing rests on: that nothing has changed the
member since. a call that mutates it, a @property that returns something new each time, or a
write through the returned value all leave the claim standing when it no longer holds — the same
way they do for assert isinstance(c.debug, bool) followed by a read
a returned predicate is summarised the same way, as a narrowing its callers can use — see type narrowing predicates
a written-out -> None¶
because None is now what a def means when it says nothing, writing it out adds a word without
adding information, and redundant-return-annotation says so
the question is only ever whether deleting the two words changes anything. where the type would
come from instead — the body, an overridden base, a sibling overload group — decides nothing, so
what is left unreported is exactly the defs that would return something other than None
def f() -> None: # ok — the body returns `Never`
raise ValueError
def g() -> None: # ok — a generator returns a generator
yield 1
an override is the case where this is easiest to get wrong. deleting the annotation makes it
inherit the base's return type, so whether -> None is redundant is decided by the base
class Base:
def m(self) -> int | None: ...
class Sub(Base):
def m(self) -> None: # ok — without it, `m` would return `int | None`
print("hi")
with neither sound-types nor infer-unannotated-signatures on, nothing is reported: there
-> None is load-bearing, since dropping it widens the return type to Unknown
a whole signature, recovered¶
the two halves compose. because the parameter is named, the return type can refer back to it, and an unannotated body gets the signature it would have been given by hand
def f(x="asdf"):
return x.startswith("foo")
# def f(x: some str = "asdf") -> x.startswith("foo")
reveal_type(f("foobar")) # True
unannotated overrides¶
an unannotated method inherits the parameter and return types of the method it overrides. the
lookup starts after the class itself — the same walk super() performs — so it finds what is
being overridden rather than the method being defined
class Base:
def m(self, a: int, b: str = "x") -> bytes: ...
class Sub(Base):
def m(self, a, b="y"):
reveal_type(a) # int
reveal_type(b) # str
return b""
Sub().m("nope") # error: invalid-argument-type
Protocol members and abstractmethod declarations are ordinary base methods for this purpose
a method that overrides nothing falls back to the analysis above. the base signature is also ignored
when it mentions a type variable (including an implicit Self), because those are bound to the base
method's own scope and copying them across would silently rebind them
bare ClassVar¶
a bare ClassVar uses its inferred type. without this, adding ClassVar — a strengthening of
intent — would degrade the type relative to writing nothing at all
empty collection literals¶
an empty collection literal has element type Never, so first([]) solves T to Never
instead of leaking Unknown. a non-empty literal is unaffected
a type variable a call leaves unsolved is a separate matter, and is covered by precise unsolved type variables, which is on by default
not covered¶
these are known gradual-guarantee costs that sound-types does not currently address
- a use the body analysis cannot read contributes nothing:
def f(x): return 1 in xleavesxgradual, becauseinruns through__contains__,__iter__or__getitem__and asking for any one of the three would demand something the body never needed. anasync for, awithand an operation whose left operand is not the parameter are left out for the same kind of reason - an operation gives way to what the program states: where a default value, an
assertor a forwarded parameter type rules the operation out, the statement wins and the operation is reported in the body where it lives rather than at every call site.def g(x=0): x + "foo"is an error ing - a call recorded twice needs one signature: two calls of the same member union position by
position, so
m.group("a")andm.group("b")ask for agroupthat takesstr. two calls of different shape would need an overload, which cannot be written here, so such a member degrades to asking only that it exist and be callable - a call site whose argument is itself an unsolved hole is where most of the remaining noise lives: the callee's requirement cannot be propagated onto the caller's own hole, because it is a shape this analysis invented, so the forward is reported instead
*args/**kwargsdo not open a hole: one type parameter cannot name a run of arguments, so they staytuple[Unknown, ...]/dict[str, Unknown], and atarget(**kwargs)forward is entirely unchecked- a lambda parameter does not open a hole, so
lambda x: xis not inferred as the identity function the waydefis - unannotated function decorators erase the signature: under
@decowheredecois unannotated, the decorated function becomesUnknownand its call sites go unchecked. ty already preserves the decorated object through an unannotated class decorator; the function case should follow - singleton promotion in collection literals:
[None]islist[None | Unknown]. fluid specializations now cover what this promotion was for - implicit attribute collections across methods:
self.items = []in__init__plusself.items.append(v)elsewhere giveslist[Unknown]; fluid specializations stop at the scope boundary Anyfrom typeshed and third-party stubs (json.loads) stays gradual
known rough edges¶
- a collection default goes through literal promotion rather than the collection-literal path, so
def g(a=[])islist[Unknown]anddef h(a=())istuple[()]