What does the implicit scope created by `def load[T]` give you that a module-level TypeVar cannot?
answer
- The compiler adds a hidden enclosure
- Nothing lands in the module namespace
- Per-declaration, not shared
- Bounds resolve on demand
- Introspect the parameters, do not import them
basics
~10 sThe compiler wraps the declaration in a hidden scope holding T, so the name never reaches the module, each declaration owns a fresh parameter rather than sharing one, and bounds resolve lazily there.
solid answer
~50 s`def load[T](...)` compiles to a hidden scope, roughly a function scope, in which `T` is bound; the definition itself is built inside it. Three things follow that a module-level `T = TypeVar("T")` cannot offer. The name does not leak: evaluating bare `T` in the module raises `NameError`, so there is no importable global for unrelated code to reuse. Every declaration mints its own `typing.TypeVar`, so two functions both written `[T]` are independent — no shared object whose bound one team can change under another. And the bound is lazy: `def parse[T: Result](...)` compiles even when `Result` is defined further down the file, because the bound expression is evaluated inside that scope only when `__bound__` is read. At runtime `T` is still visible inside the function body and, for a class, inside the class body and its methods; `__type_params__` is the supported way to reach it from outside.
code
python · 11 linesdef load[T](rows: list[T]) -> T:
return rows[0]
print(load.__type_params__)
try:
T
except NameError:
print("T never reaches module scope")
print(load([{"sodium": 141.0}]))go deeper
Remember the observable fact: after def load[T](...), typing T on its own in that module raises NameError. The parameter belongs to the declaration, and you inspect it through load.__type_params__ rather than by name.
Explain the hidden scope the compiler creates around the declaration, and the two things it buys: a fresh parameter per declaration instead of one shared object, and lazily evaluated bounds so a bound may name a class defined later in the file.
Show migration judgement. Audit every import of a shared module-level TypeVar and decide per call site whether it wanted a local parameter or a genuine shared relationship, know that a deferred bound surfaces its NameError only when read, and respect the hard 3.12 parse floor.
Own the coupling argument: a shared TypeVar imported across a package is a cross-module contract whose blast radius is invisible in a one-line diff. Decide when a shared parameter should become an explicit generic type instead, and when a runtime floor is worth the readability.
## What the compiler emits When you write `def load[T](rows: list[T]) -> T`, CPython does not simply define a function. It compiles an extra, invisible scope — you can see it named in tracebacks as `<generic parameters of load>` — creates the `typing.TypeVar` there, and defines the function *inside* that scope. The same happens for `class Box[T]` and for `type Alias[T] = ...`. The parameters live in a lexical enclosure that surrounds the declaration and nothing else. That single mechanism explains every practical difference from the pre-3.12 spelling. ## No leakage into the module namespace ```python def load[T](rows: list[T]) -> T: return rows[0] try: T except NameError: print("T never reaches module scope") print(load.__type_params__) # (T,) ``` The old form bound `T` as a module global, which made it importable. Codebases duly imported it, and one `TypeVar` object ended up annotating unrelated generics across many files. Nothing was broken by that, but it created a shared mutable-by-edit dependency: changing that object's bound or its constraint list changed the meaning of every signature that referenced it. On a clinical-lab result loader maintained by an eleven-person team, a `shared_types.T` imported into a dozen modules is precisely the kind of coupling that makes a narrowing change unreviewable — the diff touches one line and the blast radius is the whole package. With the bracket syntax there is no such object to import. ## Each declaration owns its parameter Because the parameter is created per declaration, `def f[T]` and `def g[T]` in the same module hold different `TypeVar` objects that merely share a spelling. That is exactly right for the usual case — each generic function ranges over its own type — and it removes an entire class of accidental coupling. The flip side is worth stating plainly, because it is the one thing the old style did more directly: when two functions must genuinely range over *the same* parameter, brackets cannot express it. The fix is structural rather than lexical — put both functions on a generic class `class Loader[T]`, or route them through a parameterized `type` alias — which is generally the clearer design anyway. ## Lazy evaluation inside the scope The bound (and, for an alias, the value) is not evaluated when the declaration runs. It is compiled into a function in that hidden scope and evaluated on demand: ```python def parse[T: Result](raw: str) -> T: ... class Result: ... (tv,) = parse.__type_params__ print(tv.__bound__) # <class '__main__.Result'> - resolved on access ``` A module-level `TypeVar("T", bound=Result)` is an ordinary call: `Result` must already exist, or you quote it as a string and hope a checker resolves it. Here the ordering problem disappears. If the name is genuinely never defined, you get a `NameError` at the moment something reads `__bound__` — a deferred error, not a silenced one. This is the same evaluation model Python 3.14 adopted for annotations generally under PEP 649, with `annotationlib` for inspecting them in source or value form. Since 3.14, deferred evaluation is the default across annotations, aliases and bounds rather than a special case. ## What is still visible at runtime The scope encloses the declaration, so the parameter name resolves normally inside it: `T` is a live name in the function body, and in a class body and its method bodies. It just is not visible outside. From outside, `__type_params__` is the door — a tuple of `TypeVar` objects on any bracket-declared function, class or alias, and an empty tuple elsewhere, so introspection code needs no `hasattr` guard. ## Judgement in a migration When converting a package, the mechanical edits are: delete the module-level `TypeVar` calls, delete explicit `Generic[T]` bases, move parameters into brackets. The judgement call is the reuse audit. Search for every import of the shared `TypeVar` and decide, per site, whether it wanted *a* type parameter (make it local) or *that* type parameter (make the relationship structural). Skipping that audit is how a migration silently changes what a signature means. And remember the hard floor: the syntax does not parse before 3.12, so a library supporting older runtimes keeps the old spelling until it drops them.
- Where is a bracket-declared T actually visible at runtime?Inside the declaration it encloses: the function body, or a class body and its method bodies, where the name resolves like any enclosing-scope name. Outside, it is unreachable by name — `T` raises `NameError` — and the supported access is `__type_params__` on the function, class or alias.
- What breaks if two functions need to range over the same type parameter?Brackets cannot express it: each declaration mints its own `TypeVar`, so a shared spelling is not a shared parameter. Express the relationship structurally instead — make both functions methods of a generic class, or route them through a parameterized `type` alias. That is usually a clearer statement of the design than a shared module-level object was.
- When would you keep the pre-3.12 TypeVar spelling on purpose?When the code must run on Python 3.11 or earlier. The bracket syntax is a parser feature with no `__future__` opt-in, so a file using it fails to parse on older interpreters. A library supporting a wide runtime range keeps the old spelling until it drops those versions, then converts in one pass.
A module-level TypeVar is a tool on a shared workshop bench that anyone may pick up and file down; a bracket parameter is a tool issued to one bench and stored with it.
saying these in an interview costs you the question
- Says the bracket T becomes a module-level global
- Thinks two functions written [T] share one TypeVar object
- Claims a bound must be defined before the declaration
- Believes T is unusable inside the function or class body
- Assumes the syntax can be back-ported with a __future__ import
- Treats the implicit scope as adding runtime type enforcement