← board

TypeVar(...) is an undefined variable

Reproduce

from typing import TypeVar
MessageT = TypeVar("MessageT")
print("ok")
error: undefined variable (TypeVar)

Why

typing is on PyImportRootIsConsumedOnly — the from-import is consumed and dropped, on the reasoning that what it exports is "a compile-time annotation with no run-time existence, or an ordinary pylib symbol already in scope". That is true of List, Optional, Dict and friends, which only ever appear inside annotations and are read by PyAnnTypeAt, never evaluated.

It is not true of the handful of typing names that are CALLED at run time: TypeVar, NewType, Generic, cast, overload. Those appear in ordinary statement position, so consuming the import leaves the name unbound and the program fails at the call, naming TypeVar rather than the import that dropped it — the exact "silently dropping an import is the worst shape a gap can take" failure the consumed-only list's own note warns about, arrived at from the other direction.

What the answer probably is

NilPy erases generics, and after [[bug-n-an-uninterpretable-annotation-refuses-the-program]] the resulting name is only ever used in annotations, which now degrade to Any. So the value a TypeVar call produces never has to be anything in particular — binding it to a harmless object (its own name string, say) makes every observed use work.

cast(T, x) is the one that carries real semantics and they are trivial: CPython's cast returns its second argument unchanged.

Scope Generic[...] as a base class explicitly — class Bus(Generic[MessageT]) is a different site (a base-class list, not a call) and should not be assumed to fall out.

Measured

21 of 168 git-tracked neuzelaar files fail with undefined variable, the largest remaining language gap; TypeVar is the leading single cause. Recipe: devdocs/dev/python-libraries.md §7 — regenerate rather than trusting this number.

Resolution

Four of the five typing names that have a run-time form now work; the fifth (@overload) is split out as [[bug-n-overload-decorator-is-refused]] because its answer is a different mechanism — see below.

name answer where
TypeVar pylib routine returning CPython's own ~T spelling — the result is opaque, nothing inspects it landed earlier in this ticket
NewType evaluates to the SUPERTYPE itself; the name argument is metadata and is dropped landed earlier in this ticket
cast evaluates to the SECOND argument, unchanged this pass
Generic / Protocol in a base list ERASED, exactly as object already is this pass
@overload skipping a definition, not producing a value [[bug-n-overload-decorator-is-refused]]

cast — the type is skipped as TOKENS, not parsed

CPython's cast(T, x) returns x having done nothing with T, so the value is just the expression. The first argument is stepped over as balanced tokens rather than handed to the expression parser, because it is written in annotation language: cast(List[int], x) and cast("Node", x) are ordinary, and List is itself one of the names the consumed-only import drops — parsing it would reintroduce the very undefined variable the arm exists to remove.

Guarded by PyUserNameShadowsHere('cast'), so a module with its own def cast gets its own. cast is a common enough word that hijacking it would trade one upward-compatibility break for another; the intercept only fires where the alternative was undefined variable (cast).

Generic[T] forced the base list to become a LIST

The header parsed exactly ONE base and then hard-errored on the comma. So erasing Generic in first position was not enough: class Bus(Service, Generic[T]) — the spelling typed code actually uses — still died on the multiple-inheritance refusal, for a class that names exactly one real parent.

The base parse is now a loop that counts REAL bases; erasable entries (object, Generic[...], Protocol[...]) do not count. So the refusal fires on class C(A, B) as before and does not fire on a header that only looks like multiple inheritance. Both orders work ((Service, Generic[T]) and (Generic[T], Service)), as does class E(Exception, Generic[T]).

A user class named Generic or Protocol WINS — these are erased because there is nothing to point at, so if there is, point at it.

Verified against the CPython oracle

test/test_nilpy_typevar_and_newtype.npy extended (cast in three shapes, Generic in both orders, Protocol, Exception+Generic) — output byte-identical to CPython, and it is the expected file. New test/test_nilpy_cast_user_shadow.npy pins the shadowing direction, also CPython-identical. The multiple-inheritance refusal is unchanged and still fires with its full message.

One deliberate divergence recorded in devdocs/dev/nilpy-semantics-divergences.md: instantiating a Protocol directly succeeds here and is a TypeError in CPython. Not a bug under the upward-compatibility rule — a program CPython accepts and runs never does it.

Not re-measured

The neuzelaar census could not be re-run: the corpus is not on this box. The 21-of-168 undefined variable figure in the ticket above is therefore still the 2026-08-14 number — regenerate with devdocs/dev/python-libraries.md §7 rather than trusting it.

Landmine hit

PyUserNameShadowsHere is defined ~3300 lines BELOW the new call site. pxx accepts that; FPC does not, and the seed canary caught it (Identifier not found). Fixed with a forward declaration — the same shape as bug-a-fpc-seed-drift-emitasmx64-forward.

Gate: gate.sh quick GREEN (self-host fixedpoint + --tier quick + FPC seed canary). compiler/builtin/** untouched, so no re-pin.

Log